Compare commits
19 Commits
master
...
old-skelet
Author | SHA1 | Date |
---|---|---|
matthieu42morin | 2acd807a49 | |
matthieu42morin | dc948269b9 | |
matthieu42morin | 9b976986d7 | |
matthieu42morin | c7c2ea60a2 | |
matthieu42morin | a89f69c69c | |
matthieu42morin | 35c507e55b | |
matthieu42morin | 82df5cdaa2 | |
matthieu42morin | 31fdc9a654 | |
matthieu42morin | 8c425f89c8 | |
matthieu42morin | 74987b8408 | |
matthieu42morin | dda41c8414 | |
matthieu42morin | 6ce876ab03 | |
matthieu42morin | 0170618b67 | |
matthieu42morin | 34f70f0412 | |
matthieu42morin | f691a6b377 | |
matthieu42morin | fd3084fef7 | |
matthieu42morin | e49c5001c2 | |
matthieu42morin | de6c8eb073 | |
matthieu42morin | 13c52582c4 |
|
@ -1,27 +0,0 @@
|
||||||
name: Playwright Tests
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [ main, master ]
|
|
||||||
pull_request:
|
|
||||||
branches: [ main, master ]
|
|
||||||
jobs:
|
|
||||||
test:
|
|
||||||
timeout-minutes: 60
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v3
|
|
||||||
- uses: actions/setup-node@v3
|
|
||||||
with:
|
|
||||||
node-version: 18
|
|
||||||
- name: Install dependencies
|
|
||||||
run: npm install -g pnpm && pnpm install
|
|
||||||
- name: Install Playwright Browsers
|
|
||||||
run: pnpm exec playwright install --with-deps
|
|
||||||
- name: Run Playwright tests
|
|
||||||
run: pnpm exec playwright test
|
|
||||||
- uses: actions/upload-artifact@v3
|
|
||||||
if: always()
|
|
||||||
with:
|
|
||||||
name: playwright-report
|
|
||||||
path: playwright-report/
|
|
||||||
retention-days: 30
|
|
|
@ -1,14 +1,25 @@
|
||||||
import { defineMDSveXConfig as defineConfig } from 'mdsvex';
|
import { defineMDSveXConfig as defineConfig } from 'mdsvex';
|
||||||
import remarkExternalLinks from 'remark-external-links';
|
import rehypeExternalLinks from 'rehype-external-links';
|
||||||
import remarkSetImagePath from './src/lib/utils/remark-set-image-path.js';
|
import rehypeAutolinkHeadings from 'rehype-autolink-headings';
|
||||||
import remarkLinkWithImageAsOnlyChild from './src/lib/utils/remark-link-with-image-as-only-child.js';
|
import readingTime from 'mdsvex-reading-time';
|
||||||
import rehypeImgSize from 'rehype-img-size';
|
|
||||||
|
|
||||||
import remarkUnwrapImages from 'remark-unwrap-images';
|
import remarkUnwrapImages from 'remark-unwrap-images';
|
||||||
import remarkToc from 'remark-toc';
|
import remarkToc from 'remark-toc';
|
||||||
import rehypeSlug from 'rehype-slug';
|
import rehypeSlug from 'rehype-slug';
|
||||||
|
import { visit } from 'unist-util-visit';
|
||||||
// import { highlightCode } from './src/lib/utils/highlighter.js';
|
import { toString } from 'mdast-util-to-string';
|
||||||
|
import Slugger from 'github-slugger';
|
||||||
|
import remarkFFF from 'remark-fff';
|
||||||
|
import remarkFootnotes from 'remark-footnotes';
|
||||||
|
// import { shikiTwoslashHighlighter } from '@kitbook/mdsvex-shiki-twoslash';
|
||||||
|
// import createShikiHighlighter from './src/lib/utils/shiki-highlighter.js';
|
||||||
|
// const shiki = await createShikiHighlighter({
|
||||||
|
// theme: 'github-dark-dimmed',
|
||||||
|
// showLineNumbers: (n) => true
|
||||||
|
// });
|
||||||
|
// highlighter
|
||||||
|
import { escapeSvelte } from 'mdsvex';
|
||||||
|
import { lex, parse as parseFence } from 'fenceparser';
|
||||||
|
import { renderCodeToHTML, runTwoSlash, createShikiHighlighter } from 'shiki-twoslash';
|
||||||
|
|
||||||
/** @type {import('mdsvex').MdsvexOptions} */
|
/** @type {import('mdsvex').MdsvexOptions} */
|
||||||
const config = defineConfig({
|
const config = defineConfig({
|
||||||
|
@ -16,6 +27,33 @@ const config = defineConfig({
|
||||||
smartypants: {
|
smartypants: {
|
||||||
dashes: 'oldschool'
|
dashes: 'oldschool'
|
||||||
},
|
},
|
||||||
|
// layout: {
|
||||||
|
// _: './src/lib/components/blog/Post.svelte'
|
||||||
|
// },
|
||||||
|
highlight: {
|
||||||
|
highlighter: async (code, lang, meta) => {
|
||||||
|
let fence, twoslash;
|
||||||
|
try {
|
||||||
|
fence = parseFence(lex([lang, meta].filter(Boolean).join(' ')));
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(`Could not parse the codefence for this code sample \n${code}`);
|
||||||
|
}
|
||||||
|
if (fence?.twoslash === true) twoslash = runTwoSlash(code, lang);
|
||||||
|
return `{@html \`${escapeSvelte(
|
||||||
|
renderCodeToHTML(
|
||||||
|
code,
|
||||||
|
lang,
|
||||||
|
fence ?? {},
|
||||||
|
{ themeName: 'github-dark-dimmed' },
|
||||||
|
await createShikiHighlighter({ theme: 'github-dark-dimmed' }),
|
||||||
|
twoslash
|
||||||
|
)
|
||||||
|
)}\` }`;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// highlight: {
|
||||||
|
// highlighter: shiki
|
||||||
|
// },
|
||||||
/* Wait for skeleton to implement Prismjs, for now use <CodeBlock /> in .md files */
|
/* Wait for skeleton to implement Prismjs, for now use <CodeBlock /> in .md files */
|
||||||
// layout: {
|
// layout: {
|
||||||
// blog: './src/lib/components/blog/_blog-layout.svelte',
|
// blog: './src/lib/components/blog/_blog-layout.svelte',
|
||||||
|
@ -25,7 +63,14 @@ const config = defineConfig({
|
||||||
/* Plugins */
|
/* Plugins */
|
||||||
rehypePlugins: [
|
rehypePlugins: [
|
||||||
[rehypeSlug],
|
[rehypeSlug],
|
||||||
[rehypeImgSize]
|
[rehypeAutolinkHeadings, { behavior: 'wrap' }],
|
||||||
|
[
|
||||||
|
rehypeExternalLinks,
|
||||||
|
{
|
||||||
|
rel: ['nofollow', 'noopener', 'noreferrer', 'external'],
|
||||||
|
target: '_blank'
|
||||||
|
}
|
||||||
|
]
|
||||||
// [
|
// [
|
||||||
// /** Custom rehype plugin to add loading="lazy" to all images */
|
// /** Custom rehype plugin to add loading="lazy" to all images */
|
||||||
// () => {
|
// () => {
|
||||||
|
@ -42,14 +87,24 @@ const config = defineConfig({
|
||||||
remarkPlugins: [
|
remarkPlugins: [
|
||||||
[remarkToc, { maxDepth: 3, tight: true }],
|
[remarkToc, { maxDepth: 3, tight: true }],
|
||||||
[
|
[
|
||||||
(remarkExternalLinks,
|
remarkFFF,
|
||||||
{
|
{
|
||||||
target: '_blank'
|
presets: [],
|
||||||
})
|
target: 'mdsvex',
|
||||||
|
autofill: {
|
||||||
|
provider: 'fs',
|
||||||
|
path: (path) => path.replace('/src/routes/', '/urara/')
|
||||||
|
},
|
||||||
|
strict: {
|
||||||
|
media: {
|
||||||
|
type: 'string',
|
||||||
|
array: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
],
|
],
|
||||||
[remarkUnwrapImages],
|
[readingTime, { wpm: 200 }],
|
||||||
remarkSetImagePath,
|
[remarkFootnotes, { inlineNotes: true }]
|
||||||
remarkLinkWithImageAsOnlyChild
|
|
||||||
// [
|
// [
|
||||||
// headings,
|
// headings,
|
||||||
// {
|
// {
|
||||||
|
|
27
package.json
27
package.json
|
@ -21,9 +21,14 @@
|
||||||
"test": "vitest",
|
"test": "vitest",
|
||||||
"lint": "prettier --plugin-search-dir . --check . && eslint .",
|
"lint": "prettier --plugin-search-dir . --check . && eslint .",
|
||||||
"format": "prettier --plugin-search-dir . --write .",
|
"format": "prettier --plugin-search-dir . --write .",
|
||||||
|
"generate:images": "node ./generate-responsive-image-data.js",
|
||||||
"test-ct": "playwright test -c playwright-ct.config.ts"
|
"test-ct": "playwright test -c playwright-ct.config.ts"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@bitmachina/highlighter": "1.0.0-alpha.5",
|
||||||
|
"@fontsource/cooper-hewitt": "^5.0.11",
|
||||||
|
"@fontsource/fira-mono": "^5.0.13",
|
||||||
|
"@kitbook/mdsvex-shiki-twoslash": "1.0.0-beta.31",
|
||||||
"@playwright/test": "^1.41.2",
|
"@playwright/test": "^1.41.2",
|
||||||
"@skeletonlabs/skeleton": "2.0.0",
|
"@skeletonlabs/skeleton": "2.0.0",
|
||||||
"@skeletonlabs/tw-plugin": "0.1.0",
|
"@skeletonlabs/tw-plugin": "0.1.0",
|
||||||
|
@ -32,33 +37,47 @@
|
||||||
"@tailwindcss/typography": "0.5.9",
|
"@tailwindcss/typography": "0.5.9",
|
||||||
"@types/js-cookie": "^3.0.6",
|
"@types/js-cookie": "^3.0.6",
|
||||||
"@types/node": "20.5.7",
|
"@types/node": "20.5.7",
|
||||||
|
"@types/object-hash": "^3.0.6",
|
||||||
"@types/prismjs": "^1.26.3",
|
"@types/prismjs": "^1.26.3",
|
||||||
"@typescript-eslint/eslint-plugin": "^5.62.0",
|
"@typescript-eslint/eslint-plugin": "^5.62.0",
|
||||||
"@typescript-eslint/parser": "^5.62.0",
|
"@typescript-eslint/parser": "^5.62.0",
|
||||||
"autoprefixer": "10.4.15",
|
"autoprefixer": "10.4.15",
|
||||||
|
"cloudinary": "^2.2.0",
|
||||||
"emoji-regex": "^10.3.0",
|
"emoji-regex": "^10.3.0",
|
||||||
"eslint": "^8.56.0",
|
"eslint": "^8.56.0",
|
||||||
"eslint-config-prettier": "^8.10.0",
|
"eslint-config-prettier": "^8.10.0",
|
||||||
"eslint-plugin-svelte": "^2.35.1",
|
"eslint-plugin-svelte": "^2.35.1",
|
||||||
|
"fenceparser": "^2.2.0",
|
||||||
|
"github-slugger": "^2.0.0",
|
||||||
"js-cookie": "^3.0.5",
|
"js-cookie": "^3.0.5",
|
||||||
|
"mdast-util-to-string": "^4.0.0",
|
||||||
|
"mdsvex-reading-time": "^1.0.4",
|
||||||
|
"object-hash": "^3.0.0",
|
||||||
"postcss": "8.4.29",
|
"postcss": "8.4.29",
|
||||||
"prettier": "^2.8.8",
|
"prettier": "^2.8.8",
|
||||||
"prettier-plugin-svelte": "^2.10.1",
|
"prettier-plugin-svelte": "^2.10.1",
|
||||||
|
"reading-time": "^1.5.0",
|
||||||
"rehype-autolink-headings": "^7.1.0",
|
"rehype-autolink-headings": "^7.1.0",
|
||||||
|
"rehype-external-links": "^3.0.0",
|
||||||
"rehype-img-size": "^1.0.1",
|
"rehype-img-size": "^1.0.1",
|
||||||
"rehype-slug": "^6.0.0",
|
"rehype-slug": "^6.0.0",
|
||||||
"remark-external-links": "^9.0.1",
|
"remark-fff": "^1.2.1",
|
||||||
|
"remark-footnotes": "^4.0.1",
|
||||||
"remark-toc": "^9.0.0",
|
"remark-toc": "^9.0.0",
|
||||||
"remark-unwrap-images": "^4.0.0",
|
"remark-unwrap-images": "^4.0.0",
|
||||||
"sass": "^1.71.0",
|
"sass": "^1.71.0",
|
||||||
"shiki": "^1.1.6",
|
"shiki": "^1.3.0",
|
||||||
|
"shiki-themes": "^0.2.7",
|
||||||
|
"shiki-twoslash": "^3.1.2",
|
||||||
"svelte": "^4.2.11",
|
"svelte": "^4.2.11",
|
||||||
"svelte-check": "^3.6.4",
|
"svelte-check": "^3.6.4",
|
||||||
|
"tailwind-merge": "^2.2.2",
|
||||||
"tailwindcss": "3.3.3",
|
"tailwindcss": "3.3.3",
|
||||||
"tslib": "^2.6.2",
|
"tslib": "^2.6.2",
|
||||||
"typescript": "^5.3.3",
|
"typescript": "^5.3.3",
|
||||||
"unist-util-visit": "^5.0.0",
|
"unist-util-visit": "^5.0.0",
|
||||||
"vite": "^5.1.3",
|
"vite": "^5.1.3",
|
||||||
|
"vite-imagetools": "^7.0.1",
|
||||||
"vite-plugin-tailwind-purgecss": "0.2.0",
|
"vite-plugin-tailwind-purgecss": "0.2.0",
|
||||||
"vitest": "^0.34.6"
|
"vitest": "^0.34.6"
|
||||||
},
|
},
|
||||||
|
@ -71,13 +90,15 @@
|
||||||
"@threlte/core": "^6.1.1",
|
"@threlte/core": "^6.1.1",
|
||||||
"@threlte/extras": "^8.7.5",
|
"@threlte/extras": "^8.7.5",
|
||||||
"@yushijinhun/three-minifier-rollup": "^0.4.0",
|
"@yushijinhun/three-minifier-rollup": "^0.4.0",
|
||||||
|
"front-matter": "^4.0.2",
|
||||||
"highlight.js": "11.8.0",
|
"highlight.js": "11.8.0",
|
||||||
"latest": "^0.2.0",
|
"latest": "^0.2.0",
|
||||||
"linkedom": "^0.15.6",
|
"linkedom": "^0.15.6",
|
||||||
"mdsvex": "^0.11.0",
|
"mdsvex": "^0.11.0",
|
||||||
"prismjs": "^1.29.0",
|
"prismjs": "^1.29.0",
|
||||||
"rss": "^1.2.2",
|
"rss": "^1.2.2",
|
||||||
"svelte-preprocess": "^5.1.3"
|
"svelte-preprocess": "^5.1.3",
|
||||||
|
"vanilla-lazyload": "^19.1.3"
|
||||||
},
|
},
|
||||||
"type": "module"
|
"type": "module"
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,32 +0,0 @@
|
||||||
import type { Post } from '$lib/types/post';
|
|
||||||
import type { MarkdownMetadata } from '$content/types';
|
|
||||||
import type { MdsvexImport } from './types';
|
|
||||||
import { parseReadContent } from '$content/utils';
|
|
||||||
import { error } from '@sveltejs/kit';
|
|
||||||
|
|
||||||
export function listPosts() {
|
|
||||||
const posts = import.meta.glob<Post>('./blog/*.md', {
|
|
||||||
eager: true,
|
|
||||||
import: 'metadata'
|
|
||||||
});
|
|
||||||
|
|
||||||
return parseReadContent(posts);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getPostMetadata(slug: string) {
|
|
||||||
const { post } = await getPost(slug);
|
|
||||||
return post;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getPost(slug: string) {
|
|
||||||
try {
|
|
||||||
const data: MdsvexImport<Post & MarkdownMetadata> = await import(`./blog/${slug}.md`);
|
|
||||||
|
|
||||||
return {
|
|
||||||
post: { ...data.metadata, slug },
|
|
||||||
Component: data.default
|
|
||||||
};
|
|
||||||
} catch {
|
|
||||||
throw error(404, `Unable to find blog post "${slug}"`);
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,29 +1,29 @@
|
||||||
export type SkillLevel = 'A' | 'B' | 'C'; // A: Proficient, B: Experienced, C: limited Experience
|
export type Level = 'A' | 'B' | 'C'; // A: Proficient, B: Experienced, C: limited Experience
|
||||||
|
|
||||||
export interface Skill {
|
export interface Skill {
|
||||||
title: string;
|
title: string;
|
||||||
level: SkillLevel;
|
level: Level;
|
||||||
}
|
}
|
||||||
export interface SkillSubCategory {
|
export interface SubCategory {
|
||||||
title: string;
|
title: string;
|
||||||
level: number;
|
level: number;
|
||||||
skills: Skill[];
|
skills: Skill[];
|
||||||
}
|
}
|
||||||
export interface SkillCategory {
|
export interface Category {
|
||||||
|
icon: string;
|
||||||
title: string;
|
title: string;
|
||||||
level: number;
|
level: number;
|
||||||
subCategories: SkillSubCategory[];
|
subCategories: SubCategory[];
|
||||||
}
|
}
|
||||||
// prettier-ignore
|
// prettier-ignore
|
||||||
const skillCategories: SkillCategory[] = [
|
const list: Category[] = [
|
||||||
{title:'Software Development', level: 70, subcategories: [
|
{ icon: '🚀', title:'Software Development', level: 70, subCategories: [
|
||||||
{ title:'Programming Languages', level: 75, skills: [
|
{ title:'Programming Languages', level: 75, skills: [
|
||||||
{ title: 'JavaScript/TypeScript', level: 'A' },
|
{ title: 'JavaScript/TypeScript', level: 'A' },
|
||||||
{ title: 'Python', level: 'B' },
|
{ title: 'Python', level: 'B' },
|
||||||
{ title: 'Rust', level: 'C' },
|
|
||||||
{ title: 'Bash', level: 'B' },
|
{ title: 'Bash', level: 'B' },
|
||||||
{ title: 'SQL', level: 'B' },
|
{ title: 'SQL', level: 'B' },
|
||||||
{ title: 'LaTeX', level: 'B' },
|
{ title: 'Rust', level: 'C' },
|
||||||
]},
|
]},
|
||||||
{ title:'Web Frameworks', level: 70, skills: [
|
{ title:'Web Frameworks', level: 70, skills: [
|
||||||
{ title: 'Svelte(Kit)', level: 'A' },
|
{ title: 'Svelte(Kit)', level: 'A' },
|
||||||
|
@ -45,7 +45,7 @@ const skillCategories: SkillCategory[] = [
|
||||||
{ title: 'SEO, performance optimizations', level: 'B'},
|
{ title: 'SEO, performance optimizations', level: 'B'},
|
||||||
]},
|
]},
|
||||||
]},
|
]},
|
||||||
{title:'DevOps', level: 70, subcategories: [
|
{ icon: '🔁🔁', title:'DevOps', level: 70, subCategories: [
|
||||||
{ title:'Infrastructure & Configuration Management', level: 80, skills: [
|
{ title:'Infrastructure & Configuration Management', level: 80, skills: [
|
||||||
{ title: 'Terraform & tooling', level: 'A'},
|
{ title: 'Terraform & tooling', level: 'A'},
|
||||||
{ title: 'Ansible', level: 'A'},
|
{ title: 'Ansible', level: 'A'},
|
||||||
|
@ -54,17 +54,15 @@ const skillCategories: SkillCategory[] = [
|
||||||
]},
|
]},
|
||||||
{ title: 'Version Control & CI/CD', level: 90, skills: [
|
{ title: 'Version Control & CI/CD', level: 90, skills: [
|
||||||
{ title: 'Git', level: 'A' },
|
{ title: 'Git', level: 'A' },
|
||||||
{ title: 'GitHub Ecosystem', level: 'A' },
|
{ title: 'GitHub & Gitea Ecosystem', level: 'A' },
|
||||||
{ title: 'Gitea', level: 'A' },
|
|
||||||
{ title: 'Gitlab Ecosystem', level: 'B' }
|
{ title: 'Gitlab Ecosystem', level: 'B' }
|
||||||
]},
|
]},
|
||||||
{ title: 'Monitoring & Observability ', level: 90, skills: [
|
{ title: 'Monitoring & Observability ', level: 90, skills: [
|
||||||
{ title: 'Grafana', level: 'B' },
|
{ title: 'Grafana', level: 'B' },
|
||||||
{ title: 'Prometheus', level: 'B' },
|
{ title: 'Prometheus', level: 'B' },
|
||||||
]},
|
]},
|
||||||
{title:'Vercel', level: 100, skills: []},
|
|
||||||
]},
|
]},
|
||||||
{title:'Cloud Computing', level: 70, subcategories: [
|
{ icon: '⛅', title:'Cloud Computing', level: 70, subCategories: [
|
||||||
{ title:'AWS', level: 80, skills: [
|
{ title:'AWS', level: 80, skills: [
|
||||||
{ title: 'EC2', level: 'A' },
|
{ title: 'EC2', level: 'A' },
|
||||||
{ title: 'RDS', level: 'A'},
|
{ title: 'RDS', level: 'A'},
|
||||||
|
@ -82,7 +80,7 @@ const skillCategories: SkillCategory[] = [
|
||||||
{ title:'Vercel', level: 100, skills: []},
|
{ title:'Vercel', level: 100, skills: []},
|
||||||
{ title:'DigitalOcean', level: 100, skills: []},
|
{ title:'DigitalOcean', level: 100, skills: []},
|
||||||
]},
|
]},
|
||||||
{title:'System Administration', level: 75, subcategories: [
|
{ icon: '🔧💻🔒⚙️', title:'System Administration', level: 75, subCategories: [
|
||||||
{ title:'Operating Systems', level: 80, skills: [
|
{ title:'Operating Systems', level: 80, skills: [
|
||||||
{ title: 'Debian / Ubuntu', level: 'A' },
|
{ title: 'Debian / Ubuntu', level: 'A' },
|
||||||
{ title: 'Nix(OS)', level: 'B' },
|
{ title: 'Nix(OS)', level: 'B' },
|
||||||
|
@ -105,7 +103,7 @@ const skillCategories: SkillCategory[] = [
|
||||||
{ title: 'AWS Secrets Manager', level: 'B' },
|
{ title: 'AWS Secrets Manager', level: 'B' },
|
||||||
]}
|
]}
|
||||||
]},
|
]},
|
||||||
{title:"Some fun geek skillz", level: 70, subcategories: [
|
{ icon: '', title:"Some fun geek skillz", level: 70, subCategories: [
|
||||||
{ title:'mini hardware', level: 80, skills: [
|
{ title:'mini hardware', level: 80, skills: [
|
||||||
{ title: 'Raspberry Pi', level: 'A' },
|
{ title: 'Raspberry Pi', level: 'A' },
|
||||||
{ title: 'ESP8266, ESP32', level: 'A' },
|
{ title: 'ESP8266, ESP32', level: 'A' },
|
||||||
|
@ -121,19 +119,28 @@ const skillCategories: SkillCategory[] = [
|
||||||
{ title: 'Ultimaker Cura', level: 'B' },
|
{ title: 'Ultimaker Cura', level: 'B' },
|
||||||
]},
|
]},
|
||||||
]},
|
]},
|
||||||
{title:'Languages', level: 70, skills: [
|
{ icon: '🤐💬', title:'Languages', level: 70, subCategories: [
|
||||||
{ title: 'English', level: 'A' },
|
{ title: 'English', level: 'A' },
|
||||||
{ title: 'Czech', level: 'A' },
|
{ title: 'Czech', level: 'A' },
|
||||||
{ title: 'French', level: 'B' },
|
{ title: 'French', level: 'B' },
|
||||||
{ title: 'German', level: 'C' }
|
{ title: 'German', level: 'C' }
|
||||||
]},
|
]},
|
||||||
{title:'Design', level: 70, skills: [
|
{ icon: '🎨✏️📐', title:'Design', level: 70, subCategories: [
|
||||||
{ title: 'Figma', level: 'A' },
|
|
||||||
{ title: 'UI/UX', level: 'B' },
|
{ title: 'UI/UX', level: 'B' },
|
||||||
{ title: 'LaTeX', level: 'C' },
|
{ title: 'LaTeX', level: 'C' },
|
||||||
{ title: 'Wireframing & Prototyping', level: 'B' },
|
{ title: 'Wireframing, Prototyping, Diagramming', level: 'B', skills: [
|
||||||
{ title: 'Myriads of image manipulation and generation tools', level: 'B' },
|
{ title: 'Figma', level: 'A' },
|
||||||
|
{ title: 'Devops diagram tool - '}
|
||||||
|
|
||||||
|
] },
|
||||||
|
{ title: 'Images, Diagrams, Vectors', level: 'B', skills: [
|
||||||
|
//only foss
|
||||||
|
{ title: 'Inkscape', level: 'A' },
|
||||||
|
{ title: 'Gimp', level: 'A' },
|
||||||
|
{ title: 'Blender', level: 'B'},
|
||||||
|
{ title: 'Adobe PS, AI, InDesign'}
|
||||||
|
]},
|
||||||
]}
|
]}
|
||||||
];
|
];
|
||||||
|
|
||||||
export default skillCategories;
|
export default list;
|
||||||
|
|
|
@ -1,16 +0,0 @@
|
||||||
export interface MarkdownHeading {
|
|
||||||
title: string;
|
|
||||||
slug: string;
|
|
||||||
level: number;
|
|
||||||
children: MarkdownHeading[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface MarkdownMetadata {
|
|
||||||
headings: MarkdownHeading[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface MdsvexImport<T extends MarkdownMetadata = MarkdownMetadata> {
|
|
||||||
// Technically not correct but needed to make language-tools happy
|
|
||||||
default: ConstructorOfATypedSvelteComponent;
|
|
||||||
metadata: T;
|
|
||||||
}
|
|
|
@ -1,94 +0,0 @@
|
||||||
import type { create_ssr_component } from 'svelte/internal';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sorts an array of objects by their `date` property in descending order.
|
|
||||||
* @param a - The first object to compare.
|
|
||||||
* @param b - The second object to compare.
|
|
||||||
* @returns A number that represents the difference between the parsed dates of the two objects.
|
|
||||||
*/
|
|
||||||
export function dateSort<T extends { date?: string }>(a: T, b: T): number {
|
|
||||||
return Date.parse(b.date) - Date.parse(a.date);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Renders an mdsvex component and returns the resulting HTML string.
|
|
||||||
* @param component - The mdsvex component to render.
|
|
||||||
* @returns The HTML string that was generated by rendering the component.
|
|
||||||
* @throws An error if the `render` property of the component is not a function.
|
|
||||||
*/
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
export function renderMdsvexComponent(component: any): string {
|
|
||||||
if (typeof component['render'] != 'function') {
|
|
||||||
throw new Error("Unable to render something that isn't a mdsvex component");
|
|
||||||
}
|
|
||||||
|
|
||||||
return (component as ReturnType<typeof create_ssr_component>).render().html;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Converts an md file path to a slug by removing the last segment of the path and the `.md` extension.
|
|
||||||
* @param path - The path of the md file.
|
|
||||||
* @returns The slug of the md file.
|
|
||||||
*/
|
|
||||||
export function mdPathToSlug(path: string) {
|
|
||||||
return path.split('/').at(-1).slice(0, -3);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parses an object of data that has string keys and values of type `T` that have an optional `date` property.
|
|
||||||
* @param data - The object of data to parse.
|
|
||||||
* @param dateSort - A function that sorts an array of objects by their `date` property in descending order.
|
|
||||||
* @param mdPathToSlug - A function that converts an md file path to a slug.
|
|
||||||
* @returns An array of objects that have a `slug` property and the properties of the original data objects.
|
|
||||||
*/
|
|
||||||
export function parseReadContent<T extends { date?: string }>(data: Record<string, T>): T[] {
|
|
||||||
return Object.entries(data)
|
|
||||||
.map(([file, data]) => ({
|
|
||||||
slug: mdPathToSlug(file),
|
|
||||||
...data
|
|
||||||
}))
|
|
||||||
.sort(dateSort);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Old utils
|
|
||||||
// /**
|
|
||||||
// * Formats a date string using the specified date style and locale.
|
|
||||||
// * @param date - The date string to format.
|
|
||||||
// * @param dateStyle - The style to use when formatting the date. Defaults to 'medium'.
|
|
||||||
// * @param locales - The locale to use when formatting the date. Defaults to 'en'.
|
|
||||||
// * @returns The formatted date string.
|
|
||||||
// */
|
|
||||||
// export function formatDate(date: string, dateStyle: DateStyle = 'medium', locales = 'en') {
|
|
||||||
// const formatter = new Intl.DateTimeFormat(locales, { dateStyle });
|
|
||||||
// return formatter.format(new Date(date));
|
|
||||||
// }
|
|
||||||
// type DateStyle = Intl.DateTimeFormatOptions['dateStyle'];
|
|
||||||
/**
|
|
||||||
* Formats a date string into a human-readable format.
|
|
||||||
* @param date - The date string to format.
|
|
||||||
* @returns A string representing the formatted date, or an empty string if the input is invalid.
|
|
||||||
*/
|
|
||||||
export const formatDate = (date) => {
|
|
||||||
try {
|
|
||||||
const d = new Date(date);
|
|
||||||
return `${d.toLocaleString('default', {
|
|
||||||
month: 'long'
|
|
||||||
})} ${d.getDate()}, ${d.getFullYear()}`;
|
|
||||||
} catch (e) {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const scrollIntoView = (selector: string) => {
|
|
||||||
const scrollToElement = document.querySelector(selector);
|
|
||||||
|
|
||||||
if (!scrollToElement) return;
|
|
||||||
|
|
||||||
const mediaQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
|
|
||||||
|
|
||||||
scrollToElement.scrollIntoView({
|
|
||||||
block: 'nearest',
|
|
||||||
inline: 'start',
|
|
||||||
behavior: mediaQuery.matches ? 'auto' : 'smooth'
|
|
||||||
});
|
|
||||||
};
|
|
|
@ -0,0 +1,32 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import socials from '$lib/socialsObjects';
|
||||||
|
export let mail: string = socials[0].href;
|
||||||
|
export let clazz: string = '';
|
||||||
|
export let iconClazz: string = '';
|
||||||
|
export let h: number = 16;
|
||||||
|
export let w: number = 16;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class={` ${clazz}`}>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" lang="en-GB" aria-labelledby="title">
|
||||||
|
<title id="title">Send me a mail!</title>
|
||||||
|
|
||||||
|
<defs />
|
||||||
|
|
||||||
|
<a href="mailto:{mail}" target="" rel="noreferrer" aria-label="Send me a mail!">
|
||||||
|
<rect class="fill-current text-transparent" width="100%" height="100%" />
|
||||||
|
|
||||||
|
<foreignObject x="0" y="0" width="100%" height="100%">
|
||||||
|
<div class="email-icon-wrapper">
|
||||||
|
<i class={`${iconClazz} `} />
|
||||||
|
</div>
|
||||||
|
</foreignObject>
|
||||||
|
</a>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style lang="postcss">
|
||||||
|
.email-icon-wrapper {
|
||||||
|
@apply flex items-center justify-center w-full h-full;
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -0,0 +1,53 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { website } from '$lib/config';
|
||||||
|
import type { Tag } from '$lib/types/post';
|
||||||
|
|
||||||
|
export let article = false;
|
||||||
|
export let datePublished: string | null = null;
|
||||||
|
export let lastUpdated: string | null = null;
|
||||||
|
export let featuredImage: string;
|
||||||
|
export let featuredImageAlt: string;
|
||||||
|
export let squareImage: string;
|
||||||
|
export let metadescription: string;
|
||||||
|
export let ogLanguage: string;
|
||||||
|
export let pageTitle: string;
|
||||||
|
export let siteTitle: string;
|
||||||
|
export let url: string;
|
||||||
|
export let tags: Tag[];
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<meta property="og:site_name" content={siteTitle} />
|
||||||
|
<meta property="og:locale" content={ogLanguage} />
|
||||||
|
<meta property="og:url" content={url} />
|
||||||
|
<meta property="og:type" content={article ? 'article' : 'website'} />
|
||||||
|
<meta property="og:title" content={pageTitle} />
|
||||||
|
{#if metadescription}
|
||||||
|
<meta property="og:description" content={metadescription} />
|
||||||
|
{:else}
|
||||||
|
<meta name="og:description" content={website.description} />
|
||||||
|
{/if}
|
||||||
|
{#if featuredImage}
|
||||||
|
<meta property="og:image" content={featuredImage} />
|
||||||
|
<meta property="og:image:width" content="1200" />
|
||||||
|
<meta property="og:image:height" content="627" />
|
||||||
|
<meta property="og:image:alt" content={featuredImageAlt} />
|
||||||
|
{/if}
|
||||||
|
{#if squareImage}
|
||||||
|
<meta property="og:image" content={squareImage} />
|
||||||
|
<meta property="og:image:width" content="400" />
|
||||||
|
<meta property="og:image:height" content="400" />
|
||||||
|
<meta property="og:image:alt" content={featuredImageAlt} />
|
||||||
|
{/if}
|
||||||
|
{#if article}
|
||||||
|
<!-- <meta property="article:publisher" content={facebookPage} />
|
||||||
|
<meta property="article:author" content={facebookAuthorPage} /> -->
|
||||||
|
<meta property="article:published_time" content={datePublished} />
|
||||||
|
<meta property="article:modified_time" content={lastUpdated} />
|
||||||
|
{/if}
|
||||||
|
{#if tags && tags.length > 0}
|
||||||
|
{#each tags as tag (tag)}
|
||||||
|
<meta property="article:tag" content={tag} />
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
</svelte:head>
|
|
@ -0,0 +1,207 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import hash from 'object-hash';
|
||||||
|
export let article = false;
|
||||||
|
export let author;
|
||||||
|
|
||||||
|
export let breadcrumbs: { name: string; slug: string }[] = [];
|
||||||
|
export let datePublished;
|
||||||
|
export let entity;
|
||||||
|
export let lastUpdated;
|
||||||
|
export let featuredImage;
|
||||||
|
export let metadescription;
|
||||||
|
export let siteLanguage;
|
||||||
|
export let siteTitle;
|
||||||
|
export let siteTitleAlt;
|
||||||
|
export let siteUrl = '';
|
||||||
|
export let title;
|
||||||
|
export let url;
|
||||||
|
export let githubPage;
|
||||||
|
export let linkedinProfile;
|
||||||
|
export let telegramUsername;
|
||||||
|
export let twitterUsername;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @type {{ url: string; faviconWidth: number; faviconHeight: number } | null}
|
||||||
|
*/
|
||||||
|
export let entityMeta = null;
|
||||||
|
|
||||||
|
const entityHash = hash({ author }, { algorithm: 'md5' });
|
||||||
|
|
||||||
|
const schemaOrgEntity =
|
||||||
|
entityMeta !== null
|
||||||
|
? {
|
||||||
|
'@type': ['Person', 'Organization'],
|
||||||
|
'@id': `${siteUrl}/#/schema/person/${entityHash}`,
|
||||||
|
name: author,
|
||||||
|
image: {
|
||||||
|
'@type': 'ImageObject',
|
||||||
|
'@id': `${siteUrl}/#personlogo`,
|
||||||
|
inLanguage: siteLanguage,
|
||||||
|
url: entityMeta.url,
|
||||||
|
width: entityMeta.faviconWidth,
|
||||||
|
height: entityMeta.faviconHeight,
|
||||||
|
caption: author
|
||||||
|
},
|
||||||
|
logo: {
|
||||||
|
'@id': `${siteUrl}/#personlogo`
|
||||||
|
},
|
||||||
|
sameAs: [
|
||||||
|
`https://twitter.com/${twitterUsername}`,
|
||||||
|
`https://github.com/${githubPage}`,
|
||||||
|
`https://t.me/${telegramUsername}`,
|
||||||
|
`https://linkedin.com/in/${linkedinProfile}`
|
||||||
|
]
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const schemaOrgWebsite = {
|
||||||
|
'@type': 'WebSite',
|
||||||
|
'@id': `${siteUrl}/#website`,
|
||||||
|
url: siteUrl,
|
||||||
|
name: siteTitle,
|
||||||
|
description: siteTitleAlt,
|
||||||
|
publisher: {
|
||||||
|
'@id': `${siteUrl}/#/schema/person/${entityHash}`
|
||||||
|
},
|
||||||
|
potentialAction: [
|
||||||
|
{
|
||||||
|
'@type': 'SearchAction',
|
||||||
|
target: `${siteUrl}/?s={search_term_string}`,
|
||||||
|
'query-input': 'required name=search_term_string'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
inLanguage: siteLanguage
|
||||||
|
};
|
||||||
|
|
||||||
|
const schemaOrgImageObject = {
|
||||||
|
'@type': 'ImageObject',
|
||||||
|
'@id': `${url}#primaryimage`,
|
||||||
|
inLanguage: siteLanguage,
|
||||||
|
url: featuredImage.url,
|
||||||
|
contentUrl: featuredImage.url,
|
||||||
|
width: featuredImage.width,
|
||||||
|
height: featuredImage.height,
|
||||||
|
caption: featuredImage.caption
|
||||||
|
};
|
||||||
|
|
||||||
|
const schemaOrgBreadcrumbList = {
|
||||||
|
'@type': 'BreadcrumbList',
|
||||||
|
'@id': `${url}#breadcrumb`,
|
||||||
|
itemListElement: breadcrumbs.map((element, index) => ({
|
||||||
|
'@type': 'ListItem',
|
||||||
|
position: index + 1,
|
||||||
|
item: {
|
||||||
|
'@type': 'WebPage',
|
||||||
|
'@id': `${siteUrl}/${element.slug}`,
|
||||||
|
url: `${siteUrl}/${element.slug}`,
|
||||||
|
name: element.name
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
};
|
||||||
|
|
||||||
|
const schemaOrgWebPage = {
|
||||||
|
'@type': 'WebPage',
|
||||||
|
'@id': `${url}#webpage`,
|
||||||
|
url,
|
||||||
|
name: title,
|
||||||
|
isPartOf: {
|
||||||
|
'@id': `${siteUrl}/#website`
|
||||||
|
},
|
||||||
|
primaryImageOfPage: {
|
||||||
|
'@id': `${url}#primaryimage`
|
||||||
|
},
|
||||||
|
datePublished,
|
||||||
|
dateModified: lastUpdated,
|
||||||
|
author: {
|
||||||
|
'@id': `${siteUrl}/#/schema/person/${entityHash}`
|
||||||
|
},
|
||||||
|
description: metadescription,
|
||||||
|
breadcrumb: {
|
||||||
|
'@id': `${url}#breadcrumb`
|
||||||
|
},
|
||||||
|
inLanguage: siteLanguage,
|
||||||
|
potentialAction: [
|
||||||
|
{
|
||||||
|
'@type': 'ReadAction',
|
||||||
|
target: [url]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
let schemaOrgArticle = null;
|
||||||
|
if (article) {
|
||||||
|
schemaOrgArticle = {
|
||||||
|
'@type': 'Article',
|
||||||
|
'@id': `${url}#article`,
|
||||||
|
isPartOf: {
|
||||||
|
'@id': `${url}#webpage`
|
||||||
|
},
|
||||||
|
author: {
|
||||||
|
'@id': `${siteUrl}/#/schema/person/${entityHash}`
|
||||||
|
},
|
||||||
|
headline: title,
|
||||||
|
datePublished,
|
||||||
|
dateModified: lastUpdated,
|
||||||
|
mainEntityOfPage: {
|
||||||
|
'@id': `${url}#webpage`
|
||||||
|
},
|
||||||
|
publisher: {
|
||||||
|
'@id': `${siteUrl}/#/schema/person/${entityHash}`
|
||||||
|
},
|
||||||
|
image: {
|
||||||
|
'@id': `${url}#primaryimage`
|
||||||
|
},
|
||||||
|
articleSection: ['blog'],
|
||||||
|
inLanguage: siteLanguage
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const schemaOrgPublisher = {
|
||||||
|
'@type': ['Person', 'Organization'],
|
||||||
|
'@id': `${siteUrl}/#/schema/person/${entityHash}`,
|
||||||
|
name: entity,
|
||||||
|
image: {
|
||||||
|
'@type': 'ImageObject',
|
||||||
|
'@id': `${siteUrl}/#personlogo`,
|
||||||
|
inLanguage: siteLanguage,
|
||||||
|
url: `${siteUrl}/assets/rodneylab-logo.png`,
|
||||||
|
contentUrl: `${siteUrl}/assets/rodneylab-logo.png`,
|
||||||
|
width: 512,
|
||||||
|
height: 512,
|
||||||
|
caption: entity
|
||||||
|
},
|
||||||
|
logo: {
|
||||||
|
'@id': `${siteUrl}/#personlogo`
|
||||||
|
},
|
||||||
|
sameAs: [
|
||||||
|
`https://twitter.com/${twitterUsername}`,
|
||||||
|
`https://github.com/${githubPage}`,
|
||||||
|
`https://t.me/${telegramUsername}`,
|
||||||
|
`https://linkedin.com/in/${linkedinProfile}`
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
const schemaOrgArray = [
|
||||||
|
schemaOrgEntity,
|
||||||
|
schemaOrgWebsite,
|
||||||
|
schemaOrgImageObject,
|
||||||
|
schemaOrgWebPage,
|
||||||
|
schemaOrgBreadcrumbList,
|
||||||
|
...(article ? [schemaOrgArticle] : []),
|
||||||
|
schemaOrgPublisher
|
||||||
|
];
|
||||||
|
const schemaOrgObject = {
|
||||||
|
'@context': 'https://schema.org',
|
||||||
|
'@graph': schemaOrgArray
|
||||||
|
};
|
||||||
|
let jsonLdString = JSON.stringify(schemaOrgObject);
|
||||||
|
let jsonLdScript = `
|
||||||
|
<script type="application/ld+json">
|
||||||
|
${jsonLdString}
|
||||||
|
${'<'}/script>
|
||||||
|
`;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
{@html jsonLdScript}
|
||||||
|
</svelte:head>
|
|
@ -0,0 +1,32 @@
|
||||||
|
<script>
|
||||||
|
export let article = false;
|
||||||
|
export let author;
|
||||||
|
export let twitterUsername;
|
||||||
|
export let image;
|
||||||
|
export let timeToRead = 0;
|
||||||
|
|
||||||
|
/*
|
||||||
|
* When there is an equivalent og tag present, Twitter takes that so check OpenGraph before
|
||||||
|
* adding additional tags, unless you want to override OpenGraph.
|
||||||
|
*/
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<meta name="twitter:card" content="summary_large_image" />
|
||||||
|
{#if image}
|
||||||
|
<meta name="twitter:image" content={image.url} />
|
||||||
|
{/if}
|
||||||
|
{#if twitterUsername}
|
||||||
|
<meta name="twitter:creator" content={`@${twitterUsername}`} />
|
||||||
|
<meta name="twitter:site" content={`@${twitterUsername}`} />
|
||||||
|
{/if}
|
||||||
|
<meta name="twitter:label1" content="Written by" />
|
||||||
|
<meta name="twitter:data1" content={author} />
|
||||||
|
{#if article && timeToRead > 0}
|
||||||
|
<meta name="twitter:label2" content="Est. reading time" />
|
||||||
|
<meta
|
||||||
|
name="twitter:data2"
|
||||||
|
content={timeToRead !== 1 ? `${timeToRead} minutes` : '1 minute'}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
</svelte:head>
|
|
@ -0,0 +1,106 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import defaultFeaturedImage from '$lib/assets/home/home.jpg';
|
||||||
|
import defaultOgImage from '$lib/assets/home/home-open-graph.jpg';
|
||||||
|
import defaultOgSquareImage from '$lib/assets/home/home-open-graph-square.jpg';
|
||||||
|
import defaultTwitterImage from '$lib/assets/home/home-twitter.jpg';
|
||||||
|
|
||||||
|
import { website } from '$lib/config';
|
||||||
|
import { VERTICAL_LINE_ENTITY } from '$lib/constants';
|
||||||
|
import OpenGraph from './OG.svelte';
|
||||||
|
import SchemaOrg from './SchemaOrg.svelte';
|
||||||
|
import Twitter from './Twitter.svelte';
|
||||||
|
import type { Tag } from '$lib/types/post';
|
||||||
|
const {
|
||||||
|
author,
|
||||||
|
entity,
|
||||||
|
ogLanguage,
|
||||||
|
siteLanguage,
|
||||||
|
siteShortTitle,
|
||||||
|
siteTitle,
|
||||||
|
siteUrl,
|
||||||
|
githubPage,
|
||||||
|
linkedinProfile,
|
||||||
|
telegramUsername,
|
||||||
|
twitterUsername
|
||||||
|
} = website;
|
||||||
|
|
||||||
|
export let article = false;
|
||||||
|
export let breadcrumbs: { name: string; slug: string }[] = [];
|
||||||
|
export let entityMeta = null;
|
||||||
|
export let lastUpdated;
|
||||||
|
export let datePublished;
|
||||||
|
export let metadescription: string;
|
||||||
|
export let tags: Tag[];
|
||||||
|
export let slug: string;
|
||||||
|
export let timeToRead = 0;
|
||||||
|
export let title: string;
|
||||||
|
|
||||||
|
const defaultAlt =
|
||||||
|
'picture of a person with long, curly hair, wearing a red had taking a picture with an analogue camera';
|
||||||
|
|
||||||
|
// imported props with fallback defaults
|
||||||
|
export let featuredImage: string;
|
||||||
|
export let featuredImageAlt: string | undefined = defaultAlt;
|
||||||
|
export let ogImage: string;
|
||||||
|
export let ogSquareImage: string;
|
||||||
|
export let twitterImage: string;
|
||||||
|
|
||||||
|
const url = `${siteUrl}/${slug}`;
|
||||||
|
const pageTitle = `${siteTitle} ${VERTICAL_LINE_ENTITY} ${title}`;
|
||||||
|
|
||||||
|
const openGraphProps = {
|
||||||
|
article,
|
||||||
|
datePublished,
|
||||||
|
lastUpdated,
|
||||||
|
image: ogImage,
|
||||||
|
squareImage: ogSquareImage,
|
||||||
|
metadescription,
|
||||||
|
ogLanguage,
|
||||||
|
pageTitle,
|
||||||
|
siteTitle,
|
||||||
|
url,
|
||||||
|
tags,
|
||||||
|
...(article ? { datePublished, lastUpdated } : {})
|
||||||
|
};
|
||||||
|
const schemaOrgProps = {
|
||||||
|
article,
|
||||||
|
author,
|
||||||
|
breadcrumbs,
|
||||||
|
datePublished,
|
||||||
|
entity,
|
||||||
|
lastUpdated,
|
||||||
|
entityMeta,
|
||||||
|
featuredImage,
|
||||||
|
metadescription,
|
||||||
|
siteLanguage,
|
||||||
|
siteTitle,
|
||||||
|
siteTitleAlt: siteShortTitle,
|
||||||
|
siteUrl,
|
||||||
|
title: pageTitle,
|
||||||
|
url,
|
||||||
|
githubPage,
|
||||||
|
linkedinProfile,
|
||||||
|
twitterUsername,
|
||||||
|
telegramUsername
|
||||||
|
};
|
||||||
|
const twitterProps = {
|
||||||
|
article,
|
||||||
|
author,
|
||||||
|
twitterUsername,
|
||||||
|
image: twitterImage,
|
||||||
|
timeToRead
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>{pageTitle}</title>
|
||||||
|
<meta name="description" content={metadescription} />
|
||||||
|
<meta
|
||||||
|
name="robots"
|
||||||
|
content="index, follow, max-snippet:-1, max-image-preview:large, max-video-preview:-1"
|
||||||
|
/>
|
||||||
|
<link rel="canonical" href={url} />
|
||||||
|
</svelte:head>
|
||||||
|
<Twitter {...twitterProps} />
|
||||||
|
<OpenGraph {...openGraphProps} />
|
||||||
|
<SchemaOrg {...schemaOrgProps} />
|
|
@ -1,18 +0,0 @@
|
||||||
<script lang="ts">
|
|
||||||
import skills from '$content/skills';
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div
|
|
||||||
class="card flex flex-col items-center justify-center mx-auto w-3/4 bg-surface-50 bg-opacity-50 p-8 m-8"
|
|
||||||
id="skills"
|
|
||||||
>
|
|
||||||
<h2 class="h2 m-2">My skillset</h2>
|
|
||||||
{#each skills as skill}
|
|
||||||
<div class="text-lg font-bold m-2">{skill.title}</div>
|
|
||||||
<div class="flex flex-wrap justify-center space-x-2 m-2">
|
|
||||||
{#each skill.list as s}
|
|
||||||
<div class="chip variant-outline-primary">{s}</div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
|
@ -1,14 +1,24 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import MatrixLogo from './logos/MatrixLogo.svelte';
|
import MatrixLogo from '$lib/components/logos/MatrixLogo.svelte';
|
||||||
import GiteaLogo from './logos/GiteaLogo.svelte';
|
import GiteaLogo from '$lib/components/logos/GiteaLogo.svelte';
|
||||||
import { socialLinks } from '$lib/config';
|
import socials from '$lib/socialsObjects';
|
||||||
|
import ObfuscatedEmail from '$lib/components/ObfuscatedEmail.svelte';
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="flex flex-rows-auto gap-1 max-h-28">
|
<div class="flex flex-rows-auto gap-1 max-h-28">
|
||||||
{#each socialLinks as link}
|
{#each socials as link}
|
||||||
|
{#if link.title === 'Email'}
|
||||||
|
<ObfuscatedEmail
|
||||||
|
mail={socials[0].href}
|
||||||
|
clazz="logo-item w-[43px]"
|
||||||
|
h={48}
|
||||||
|
w={48}
|
||||||
|
iconClazz={socials[0].icon + ' text-3xl md:text-5xl'}
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
<a
|
<a
|
||||||
class="logo-item"
|
class="logo-item"
|
||||||
href={link.href}
|
href={link.title === 'Email' ? `mailto:${link.href}` : link.href}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel={link.title === 'Mastodon' ? 'me' : 'noreferrer'}
|
rel={link.title === 'Mastodon' ? 'me' : 'noreferrer'}
|
||||||
aria-label={link.title}
|
aria-label={link.title}
|
||||||
|
@ -21,5 +31,6 @@
|
||||||
<i class={link.icon + ' text-3xl md:text-5xl'} />
|
<i class={link.icon + ' text-3xl md:text-5xl'} />
|
||||||
{/if}
|
{/if}
|
||||||
</a>
|
</a>
|
||||||
|
{/if}
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -1,11 +0,0 @@
|
||||||
<script lang="ts">
|
|
||||||
import PostLayout from './PostLayout.svelte';
|
|
||||||
|
|
||||||
import type { Post } from '$lib/types/post';
|
|
||||||
|
|
||||||
export let post: Post;
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<PostLayout {...post} imagesDirectoryName="blog">
|
|
||||||
<slot />
|
|
||||||
</PostLayout>
|
|
|
@ -1,12 +1,11 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { Tag } from '$lib/types/post';
|
import type { Tag } from '$lib/types/post';
|
||||||
export let selected: Tag | null = null;
|
export let selected: Tag | null = null;
|
||||||
let className = '';
|
export let className = '';
|
||||||
export { className as class };
|
|
||||||
import { page } from '$app/stores';
|
import { page } from '$app/stores';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
|
|
||||||
let options: Tag[] = ['DevOps', 'Philosophy', 'Updates'];
|
const tags: Tag[] = ['Blog', 'Projects', 'Updates'];
|
||||||
|
|
||||||
const clickHandler = (value: Tag) => {
|
const clickHandler = (value: Tag) => {
|
||||||
if (value === selected) {
|
if (value === selected) {
|
||||||
|
@ -24,7 +23,7 @@
|
||||||
<section class="flex justify-center flex-col items-center {className}">
|
<section class="flex justify-center flex-col items-center {className}">
|
||||||
<h3 class="h3 mb-2 md:mb-3">Sort by category</h3>
|
<h3 class="h3 mb-2 md:mb-3">Sort by category</h3>
|
||||||
<ul class="flex flex-wrap justify-center gap-2">
|
<ul class="flex flex-wrap justify-center gap-2">
|
||||||
{#each options as option}
|
{#each tags as option}
|
||||||
<li>
|
<li>
|
||||||
<button
|
<button
|
||||||
class="chip {option === selected
|
class="chip {option === selected
|
||||||
|
|
|
@ -0,0 +1,75 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import Preview from '$lib/components/blog/Preview.svelte';
|
||||||
|
import CategoryFilter from '$lib/components/blog/CategoryFilter.svelte';
|
||||||
|
import type { Post, Tag } from '$lib/types/post';
|
||||||
|
import { H_ELLIPSIS_ENTITY } from '$lib/constants';
|
||||||
|
import { page } from '$app/stores';
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import type { PageData } from './$types';
|
||||||
|
|
||||||
|
export let data: PageData;
|
||||||
|
export let posts: Post[] = [];
|
||||||
|
|
||||||
|
let filter: Tag | null = null;
|
||||||
|
export const displayAmount = 8;
|
||||||
|
|
||||||
|
$: showPosts = displayAmount;
|
||||||
|
$: postCount = posts.length;
|
||||||
|
$: posts = data.posts.filter((post: Post) => (filter ? post.tags?.includes(filter) : true));
|
||||||
|
$: displayPosts = posts.slice(displayAmount);
|
||||||
|
|
||||||
|
const handleClick = () => {
|
||||||
|
showPosts += displayAmount;
|
||||||
|
};
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
const tagParam = $page.url.searchParams.get('tag');
|
||||||
|
|
||||||
|
if (!filter && typeof tagParam == 'string') {
|
||||||
|
filter = tagParam as Tag;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<section role="feed">
|
||||||
|
<div class="space-y-8">
|
||||||
|
<header class="flex flex-col justify-center items-center">
|
||||||
|
<h1 class="h1 m-4">Blog</h1>
|
||||||
|
<CategoryFilter className="mb-2 md:mb-4" bind:selected={filter} />
|
||||||
|
</header>
|
||||||
|
<div
|
||||||
|
class="grid m-auto max-w-7xl w-full gap-6 grid-cols-none justify-center md:grid-cols-2 lg:grid-cols-3"
|
||||||
|
>
|
||||||
|
{#each posts.slice(0, displayAmount) as post, index}
|
||||||
|
<article
|
||||||
|
class="flex justify-center min-w-[20rem] max-w-sm"
|
||||||
|
aria-posinset={index + 1}
|
||||||
|
aria-setsize={postCount}
|
||||||
|
>
|
||||||
|
<Preview {post} type="blog" />
|
||||||
|
</article>
|
||||||
|
{:else}
|
||||||
|
<p>No posts yet!</p>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if posts.slice(displayAmount).length > 0}
|
||||||
|
<div>
|
||||||
|
<h2 class="mb-4 text-center">Previous posts</h2>
|
||||||
|
<div
|
||||||
|
class="previous grid m-auto max-w-7xl w-full gap-6 grid-cols-none justify-center md:grid-cols-2 lg:grid-cols-3"
|
||||||
|
>
|
||||||
|
{#each displayPosts as post}
|
||||||
|
<div class="flex justify-center min-w-[20rem] max-w-sm">
|
||||||
|
<Preview {post} type="blog" />
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if showPosts < postCount}
|
||||||
|
<button type="submit" on:click={handleClick}>See more {H_ELLIPSIS_ENTITY}</button>
|
||||||
|
{/if}
|
||||||
|
</section>
|
|
@ -0,0 +1,3 @@
|
||||||
|
<article class="text-token prose prose-slate mx-auto dark:prose-invert lg:prose-lg">
|
||||||
|
<slot />
|
||||||
|
</article>
|
|
@ -1,63 +1,69 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { formatDate } from '$content/utils';
|
import SEO from '$lib/components/SEO/index.svelte';
|
||||||
import '$lib/assets/prism-nord.css';
|
import '$lib/assets/prism-nord.css';
|
||||||
|
import type { Post } from '$lib/types/post';
|
||||||
|
import Tags from '$lib/components/blog/Tags.svelte';
|
||||||
|
|
||||||
export let imagesDirectoryName: string;
|
export let post: Post;
|
||||||
export let excerpt: string = '';
|
|
||||||
export let date: string = '';
|
const {
|
||||||
export let slug: string = '';
|
postTitle,
|
||||||
export let title: string;
|
datePublished,
|
||||||
export let image: string;
|
featuredImage,
|
||||||
export let tags: string[] = [];
|
featuredImageAlt,
|
||||||
export let type: 'blog' | 'projects';
|
lastUpdated,
|
||||||
|
seoMetaDescription: metadescription,
|
||||||
|
slug,
|
||||||
|
timeToRead,
|
||||||
|
ogImage,
|
||||||
|
ogSquareImage,
|
||||||
|
twitterImage
|
||||||
|
} = post || {};
|
||||||
|
|
||||||
|
const breadcrumbs = [
|
||||||
|
{ name: 'Home', slug: '' },
|
||||||
|
{ name: 'type', slug: post.type },
|
||||||
|
{ name: postTitle, slug }
|
||||||
|
];
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<SEO
|
||||||
<title>{title}</title>
|
article
|
||||||
<meta name="description" content={excerpt} />
|
{breadcrumbs}
|
||||||
<meta property="og:title" content={title} />
|
{slug}
|
||||||
<meta property="og:type" content="article" />
|
{postTitle}
|
||||||
<meta property="og:description" content={excerpt} />
|
{datePublished}
|
||||||
<meta property="og:url" content="https://mattmor.in/{slug}" />
|
{lastUpdated}
|
||||||
<meta property="og:image" content={image} />
|
{metadescription}
|
||||||
<meta property="og:type" content="article:published_time" />
|
{timeToRead}
|
||||||
<meta property="article:published_time" content={date} />
|
{featuredImage}
|
||||||
{#each tags as tag (tag)}
|
{ogImage}
|
||||||
<meta property="article:tag" content={tag} />
|
{ogSquareImage}
|
||||||
{/each}
|
{twitterImage}
|
||||||
</svelte:head>
|
/>
|
||||||
|
|
||||||
<article class="flex justify-center mt-4 mb-8">
|
<section class="flex justify-center mt-4 mb-8">
|
||||||
<div class=" w-full md:w-[50rem] leading-[177.7%]">
|
<div
|
||||||
|
class=" w-full md:w-[50rem] leading-[177.7%] bg-white/50 dark:bg-black/50 m-2 rounded-t-lg mx-auto"
|
||||||
|
>
|
||||||
<header>
|
<header>
|
||||||
<img
|
<img
|
||||||
src="/images/{imagesDirectoryName}/{slug}/{image}"
|
src={featuredImage}
|
||||||
alt={`${title}`}
|
alt={featuredImageAlt}
|
||||||
class=" bg-black/50 w-full aspect-[21/9] max-h-[540px] rounded-t-lg"
|
class=" w-full aspect-[21/9] max-h-[540px] rounded-t-lg"
|
||||||
/>
|
/>
|
||||||
</header>
|
</header>
|
||||||
<div class="flex-auto flex justify-between items-center py-4 px-2 bg-surface-900">
|
<Tags {post} />
|
||||||
{#if tags && tags.length > 0}
|
<div class="space-y-4 m-8">
|
||||||
<div class="flex mb-2 items-center gap-2">
|
<h2 class="h2" data-toc-ignore>{post.title}</h2>
|
||||||
tags: {#each tags as tag}
|
<div class="max-w-none md:w-[720px]">
|
||||||
<a
|
|
||||||
data-sveltekit-preload-data="hover"
|
|
||||||
href="/blog?{new URLSearchParams({ tag }).toString()}"
|
|
||||||
>
|
|
||||||
<span class="chip variant-ghost-surface">{tag}</span>
|
|
||||||
</a>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
<small>On {formatDate(date)}</small>
|
|
||||||
</div>
|
|
||||||
<div class="space-y-4">
|
|
||||||
<h2 class="h2" data-toc-ignore>{title}</h2>
|
|
||||||
<div class="max-w-none text-token">
|
|
||||||
<slot />
|
<slot />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<hr class="opacity-50" />
|
<hr class="opacity-50" />
|
||||||
<footer class="p-4 flex justify-start items-center space-x-4" />
|
<footer class="p-4 flex justify-start items-center space-x-4" />
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</section>
|
||||||
|
|
||||||
|
<style lang="postcss">
|
||||||
|
</style>
|
||||||
|
|
|
@ -1,10 +1,10 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { isAnExternalLink } from '$lib/utils/helpers';
|
import { isAnExternalLink, generateURL } from '$lib/utils/helpers';
|
||||||
import type { Post } from '$lib/types/post';
|
import type { Post } from '$lib/types/post';
|
||||||
import { onMount, onDestroy } from 'svelte';
|
import { onMount, onDestroy } from 'svelte';
|
||||||
|
import { formatDate } from '$lib/utils/blog';
|
||||||
|
|
||||||
export let isMostRecent: boolean = false;
|
export let type: Post['type'];
|
||||||
export let type: Post['type'] = 'blog' | 'projects';
|
|
||||||
export let post: Post;
|
export let post: Post;
|
||||||
// export let published: boolean;
|
// export let published: boolean;
|
||||||
// export let headlineOrder: 'h3' | '' = '';
|
// export let headlineOrder: 'h3' | '' = '';
|
||||||
|
@ -13,7 +13,7 @@
|
||||||
|
|
||||||
//window width
|
//window width
|
||||||
let iteration = 0;
|
let iteration = 0;
|
||||||
let interval;
|
let interval: string | number | NodeJS.Timeout | undefined;
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
const interval = setInterval(() => {
|
const interval = setInterval(() => {
|
||||||
|
@ -26,20 +26,10 @@
|
||||||
}, 1000);
|
}, 1000);
|
||||||
});
|
});
|
||||||
|
|
||||||
const generateURL = (href?: string, slug?: string) => {
|
$: href = generateURL(post['href'], post.type, post.slug);
|
||||||
if (href) return href;
|
|
||||||
return `/${type}/${slug}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
$: href = generateURL(post['href'], post.slug);
|
|
||||||
|
|
||||||
$: target = post && post['href'] && isAnExternalLink(post['href']) ? '_blank' : undefined;
|
$: target = post && post['href'] && isAnExternalLink(post['href']) ? '_blank' : undefined;
|
||||||
|
|
||||||
const displayDate = new Date(Date.parse(post.date ?? '')).toLocaleDateString(undefined, {
|
|
||||||
year: 'numeric',
|
|
||||||
month: 'short',
|
|
||||||
day: 'numeric'
|
|
||||||
});
|
|
||||||
onDestroy(() => {
|
onDestroy(() => {
|
||||||
clearInterval(interval);
|
clearInterval(interval);
|
||||||
});
|
});
|
||||||
|
@ -53,10 +43,15 @@
|
||||||
<!-- Blog in long cols, projects in wide rows -->
|
<!-- Blog in long cols, projects in wide rows -->
|
||||||
<div class="flex {type === 'blog' ? 'flex-col' : 'flex-row'} justify-between w-full h-full">
|
<div class="flex {type === 'blog' ? 'flex-col' : 'flex-row'} justify-between w-full h-full">
|
||||||
<header>
|
<header>
|
||||||
<img
|
<!-- <img
|
||||||
src={`/images/${type}/${post.slug}/${post.image}`}
|
src={`/images/${type}/${post.slug}/${post.image}`}
|
||||||
class="bg-black/200 w-full aspect-[3/2]"
|
class="bg-black/200 w-full aspect-[3/2]"
|
||||||
alt="Post preview"
|
alt="Post preview"
|
||||||
|
/> -->
|
||||||
|
<img
|
||||||
|
src={post.featuredImage}
|
||||||
|
alt={post.title}
|
||||||
|
class="bg-black/200 h-[448px] w-[672px] aspect-[3/2]"
|
||||||
/>
|
/>
|
||||||
</header>
|
</header>
|
||||||
<section class="p-4 space-y-4">
|
<section class="p-4 space-y-4">
|
||||||
|
@ -88,9 +83,9 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-auto">
|
<div class="mt-auto">
|
||||||
<small>
|
<small>
|
||||||
{#if post.date}
|
{#if post.datePublished}
|
||||||
<span class="text-sm ml-4">
|
<span class="text-sm ml-4">
|
||||||
{displayDate}
|
{formatDate(post.datePublished)}
|
||||||
</span>
|
</span>
|
||||||
{/if}
|
{/if}
|
||||||
</small>
|
</small>
|
|
@ -0,0 +1,22 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import type { Post } from '$lib/types/post';
|
||||||
|
import { formatDate } from '$lib/utils/blog';
|
||||||
|
|
||||||
|
export let post: Post;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<section class="flex-auto flex justify-between items-center py-4 px-2 m-8">
|
||||||
|
{#if post.tags && post.tags.length > 0}
|
||||||
|
<div class="flex mb-2 items-center gap-2">
|
||||||
|
tags: {#each post.tags as tag}
|
||||||
|
<a
|
||||||
|
data-sveltekit-preload-data="hover"
|
||||||
|
href="/{post.type}{new URLSearchParams({ tag }).toString()}"
|
||||||
|
>
|
||||||
|
<span class="chip variant-ghost-surface">{tag}</span>
|
||||||
|
</a>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<small>On {formatDate(post.datePublished)}</small>
|
||||||
|
</section>
|
|
@ -1,24 +1,27 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import QuickLinks from '$lib/components/home/QuickLinks.svelte';
|
import QuickLinks from '$lib/components/home/QuickLinks.svelte';
|
||||||
</script>
|
</script>
|
||||||
<section class="grid grid-cols-1 lg:grid-cols-2 mx-4">
|
|
||||||
|
<section class="grid grid-cols-1 lg:grid-cols-2 items-center mx-4">
|
||||||
<!-- Text and links-->
|
<!-- Text and links-->
|
||||||
<div class="order-1 max-w-3/4 space-y-8">
|
<div class="order-1 max-w-3/4 space-y-8">
|
||||||
<h1 class="h1">Hello, I'm Matt.</h1>
|
<h1 class="h1">Hello, I'm Matt.</h1>
|
||||||
|
|
||||||
<p class="text-xl opacity-75" data-svelte-h="svelte-169iyno">
|
<p class="text-xl opacity-75">
|
||||||
A dev with an array of skills from from frontend and devops to design. I have a
|
A 🧠 that consumes ⚡️ and produces 👾 bug$... No sorry, produces code that works 100%
|
||||||
strong passion for innovation and change in tech, automation and solving 👾 problems
|
of the time. Really.
|
||||||
.
|
|
||||||
<br />From wearing a lot of 🤠 hats in past projects and startups I became a
|
<br /> Usually, this creature you are reading about exhibits "passion" for innovation
|
||||||
generalist, now I am actively deepening my knowledge in software:
|
and change. It gets dopamine and reward stimuli from solving problems ~~~ the "Ahaa"
|
||||||
<span
|
moments ~~~ <br />although the 👾 bugs produce different emotions sometimes.
|
||||||
|
|
||||||
|
<br />Matt's main professional exploits are:
|
||||||
|
<br /><span
|
||||||
class=" text-4xl bg-gradient-to-r from-primary-800 via-secondary-900 to-tertiary-900 dark:from-primary-400 dark:via-secondary-400 dark:to-tertiary-400 text-transparent bg-clip-text"
|
class=" text-4xl bg-gradient-to-r from-primary-800 via-secondary-900 to-tertiary-900 dark:from-primary-400 dark:via-secondary-400 dark:to-tertiary-400 text-transparent bg-clip-text"
|
||||||
>DevOps, CyberSec and AI.</span
|
>DevOps, Web, AI & IoT</span
|
||||||
>
|
>
|
||||||
</p>
|
</p>
|
||||||
<QuickLinks />
|
<QuickLinks />
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
<!-- Logo and buttons-->
|
<!-- Logo and buttons-->
|
||||||
<div class="order-2 hidden lg:block">
|
<div class="order-2 hidden lg:block">
|
||||||
|
|
|
@ -3,24 +3,24 @@
|
||||||
<i class="fa-solid fa-screwdriver-wrench text-4xl text-primary-500" />
|
<i class="fa-solid fa-screwdriver-wrench text-4xl text-primary-500" />
|
||||||
<h3 class="h3">Development</h3>
|
<h3 class="h3">Development</h3>
|
||||||
<p class="opacity-75">
|
<p class="opacity-75">
|
||||||
I possess a wide range of skills that enable me to develop visually appealing and
|
I hone my problem solving and coding skills all the time. I try to learn the underlying
|
||||||
interactive user interfaces for web applications.
|
mechanics and use abstractions where relevant.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="card variant-ringed-hollow p-4 md:p-8 space-y-4">
|
<div class="card variant-ringed-hollow p-4 md:p-8 space-y-4">
|
||||||
<i class="fa-solid fa-palette text-4xl text-primary-500" />
|
<i class="fa-solid fa-palette text-4xl text-primary-500" />
|
||||||
<h3 class="h3">Design</h3>
|
<h3 class="h3">Creativity & Presentation</h3>
|
||||||
<p class="opacity-75">
|
<p class="opacity-75">
|
||||||
Over the years, I have honed my ability to create visually appealing interfaces that
|
I try to create better, novel ways to approach problems and give my best to present them
|
||||||
are both user-friendly and intuitive.
|
in a clear and concise manner.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="card variant-ringed-hollow p-4 md:p-8 space-y-4">
|
<div class="card variant-ringed-hollow p-4 md:p-8 space-y-4">
|
||||||
<i class="fa-solid fa-users text-4xl text-primary-500" />
|
<i class="fa-solid fa-users text-4xl text-primary-500" />
|
||||||
<h3 class="h3">User Experience</h3>
|
<h3 class="h3">Teamwork & Stakeholders</h3>
|
||||||
<p class="opacity-75">
|
<p class="opacity-75">
|
||||||
I understand the importance of creating a seamless UX for end-users. Which includes
|
From my experience with a wide range of roles I understand the how to communication with
|
||||||
a solid understanding user behavior.
|
stakeholders across an organization.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
|
@ -1,28 +1,27 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import * as conf from '$lib/config';
|
|
||||||
import GiteaLogo from '$lib/components/logos/GiteaLogo.svelte';
|
import GiteaLogo from '$lib/components/logos/GiteaLogo.svelte';
|
||||||
|
import socials from '$lib/socialsObjects';
|
||||||
|
import ObfuscatedEmail from '../ObfuscatedEmail.svelte';
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="flex space-x-4">
|
<div class="flex space-x-4">
|
||||||
<a
|
<a
|
||||||
class="btn btn-icon variant-soft-primary hover:variant-filled-primary"
|
class="btn btn-icon variant-soft-primary hover:variant-filled-primary"
|
||||||
href={conf.socialLinks[2].href}
|
href={socials[3].href}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noreferrer"
|
rel="noreferrer"
|
||||||
title="Gitea - private github"
|
title="Gitea - private github"
|
||||||
><GiteaLogo clazz="w-6" />
|
><GiteaLogo clazz="w-6" />
|
||||||
</a><a
|
</a><a
|
||||||
class="btn btn-icon variant-soft-primary hover:variant-filled-primary"
|
class="btn btn-icon variant-soft-primary hover:variant-filled-primary"
|
||||||
href={conf.socialLinks[0].href}
|
href={socials[1].href}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noreferrer"
|
rel="noreferrer"
|
||||||
title="LinkedIn"
|
title={socials[1].title}
|
||||||
><i class="fa-brands fa-linkedin" />
|
><i class={socials[1].icon} />
|
||||||
</a><a
|
</a><ObfuscatedEmail
|
||||||
class="btn btn-icon variant-soft-primary hover:variant-filled-primary"
|
mail={socials[0].href}
|
||||||
href="mailto:{conf.email}"
|
clazz="btn btn-icon variant-soft-primary hover:variant-filled-primary h-[43px]"
|
||||||
target=""
|
iconClazz={socials[0].icon}
|
||||||
rel="noreferrer"
|
/>
|
||||||
title="Email"
|
|
||||||
><i class="fa-solid fa-envelope" />
|
|
||||||
</a>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -3,8 +3,6 @@
|
||||||
const drawerStore = getDrawerStore();
|
const drawerStore = getDrawerStore();
|
||||||
import { NavRoutes } from '$lib/config';
|
import { NavRoutes } from '$lib/config';
|
||||||
import { page } from '$app/stores';
|
import { page } from '$app/stores';
|
||||||
import { browser } from '$app/environment';
|
|
||||||
// what is my url?
|
|
||||||
$: classesDrawer = $drawerStore.id === 'mobile-nav' ? 'md:hidden' : '';
|
$: classesDrawer = $drawerStore.id === 'mobile-nav' ? 'md:hidden' : '';
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
|
@ -14,11 +14,19 @@
|
||||||
<!-- <div class="container px-5 py-8 mx-auto flex items-center sm:flex-row flex-col"> -->
|
<!-- <div class="container px-5 py-8 mx-auto flex items-center sm:flex-row flex-col"> -->
|
||||||
<a class="items-center md:justify-start justify-center" href="/">
|
<a class="items-center md:justify-start justify-center" href="/">
|
||||||
<p class="sm:pl-4 text-base sm:py-2 sm:mt-0 mt-4 text-center">
|
<p class="sm:pl-4 text-base sm:py-2 sm:mt-0 mt-4 text-center">
|
||||||
All content, unless otherwise stated,
|
All content on this website, unless otherwise stated,
|
||||||
<br>by Matthieu Morin, is under © copyright {year},
|
<br />by Matthieu Morin, is under copyright © {year},
|
||||||
<br>and all of it licensed under <a class="anchor font-bold" href="https://creativecommons.org/licenses/by-sa/4.0/deed.en">CC BY-SA 4.0</a>.
|
<br />all of it licensed under
|
||||||
<br>This site coded by me is <a class="font-bold anchor" href="https://git.mattmor.in/Madmin/its-personal/src/branch/master/LICENSE">MIT Licensed</a>.
|
<a
|
||||||
|
class="anchor font-bold"
|
||||||
|
href="https://creativecommons.org/licenses/by-sa/4.0/deed.en">CC BY-SA 4.0</a
|
||||||
|
>.
|
||||||
|
<br />This site, coded by me, is
|
||||||
|
<a
|
||||||
|
class="font-bold anchor"
|
||||||
|
href="https://git.mattmor.in/Madmin/its-personal/src/branch/master/LICENSE"
|
||||||
|
>MIT Licensed</a
|
||||||
|
>.
|
||||||
</p>
|
</p>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -1,10 +1,9 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { LightSwitch, AppBar, Avatar, getDrawerStore, Drawer } from '@skeletonlabs/skeleton';
|
import { LightSwitch, Avatar, getDrawerStore } from '@skeletonlabs/skeleton';
|
||||||
import type { DrawerSettings } from '@skeletonlabs/skeleton';
|
import type { DrawerSettings } from '@skeletonlabs/skeleton';
|
||||||
const drawerStore = getDrawerStore();
|
const drawerStore = getDrawerStore();
|
||||||
|
|
||||||
// Components
|
// Components
|
||||||
import { getImageLink } from '$lib/images';
|
|
||||||
import { page } from '$app/stores';
|
import { page } from '$app/stores';
|
||||||
import { NavRoutes } from '$lib/config';
|
import { NavRoutes } from '$lib/config';
|
||||||
|
|
||||||
|
@ -23,9 +22,6 @@
|
||||||
};
|
};
|
||||||
drawerStore.open(drawerSettings);
|
drawerStore.open(drawerSettings);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Local
|
|
||||||
const imgPlaceholder = getImageLink({ id: 'linky', w: 128, h: 128 });
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<section
|
<section
|
||||||
|
|
|
@ -1,10 +0,0 @@
|
||||||
<script lang="ts">
|
|
||||||
import ProjectContentLayout from '$lib/components/blog/PostLayout.svelte';
|
|
||||||
import type { Post } from '$lib/types/post';
|
|
||||||
|
|
||||||
export let post: Post;
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<ProjectContentLayout {...post} imagesDirectoryName="projects">
|
|
||||||
<slot />
|
|
||||||
</ProjectLayout>
|
|
|
@ -0,0 +1,57 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { AccordionItem, ProgressBar } from '@skeletonlabs/skeleton';
|
||||||
|
|
||||||
|
import type { Skill } from '$content/skills';
|
||||||
|
import list from '$content/skills';
|
||||||
|
|
||||||
|
function sortSkills(skills: Skill[]): Skill[] {
|
||||||
|
return skills.sort((a, b) => {
|
||||||
|
if (a.level < b.level) return -1;
|
||||||
|
if (a.level > b.level) return 1;
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#each list as category}
|
||||||
|
<AccordionItem>
|
||||||
|
<svelte:fragment slot="lead">
|
||||||
|
<i class="text-3xl">{category.icon}</i>
|
||||||
|
</svelte:fragment>
|
||||||
|
<svelte:fragment slot="summary">
|
||||||
|
<h2 class="h2 font-bold m-2">{category.title}</h2>
|
||||||
|
<!-- Progresses are stupidly subjective and I don't know how to grade this so no progress bars for now.
|
||||||
|
<ProgressBar class="min-w-[100px] h-2" value={category.level} max={100} /> -->
|
||||||
|
</svelte:fragment>
|
||||||
|
<svelte:fragment slot="content">
|
||||||
|
<div class="flex flex-col justify-center m-2 space-y-8">
|
||||||
|
{#if category.subCategories}
|
||||||
|
{#each category.subCategories as subCategory (subCategory.title)}
|
||||||
|
<div class="flex flex-col space-y-2">
|
||||||
|
<div class="flex flex-row justify-center items-center">
|
||||||
|
<p class="text-xl font-medium m-2">{subCategory.title}</p>
|
||||||
|
<!-- <ProgressBar value={subCategory.level} max={100} /> -->
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap justify-center space-x-2 m-2">
|
||||||
|
{#if subCategory.skills}
|
||||||
|
{#each sortSkills(subCategory.skills) as skill (skill.title)}
|
||||||
|
<span
|
||||||
|
class="chip {skill.level === 'A'
|
||||||
|
? 'variant-filled-primary'
|
||||||
|
: skill.level === 'B'
|
||||||
|
? 'variant-outline-primary'
|
||||||
|
: 'variant-outline-tertiary'}"
|
||||||
|
>
|
||||||
|
{skill.title}
|
||||||
|
</span>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</svelte:fragment>
|
||||||
|
</AccordionItem>
|
||||||
|
{/each}
|
|
@ -0,0 +1,27 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { Accordion } from '@skeletonlabs/skeleton';
|
||||||
|
import IndividualSkills from './IndividualSkills.svelte';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<Accordion
|
||||||
|
class="card flex flex-col items-center justify-center mx-auto w-3/4 bg-surface-50 bg-opacity-50 p-8 m-8 space-y-4"
|
||||||
|
autocollapse
|
||||||
|
id="skills"
|
||||||
|
>
|
||||||
|
<h2 class="h2 m-2">My skillset</h2>
|
||||||
|
<p class="text-center">
|
||||||
|
Below is a list of tools, frameworks, languages and skills <br />I use or have used to
|
||||||
|
varying degrees and a subjective rating
|
||||||
|
</p>
|
||||||
|
<span class="h6 m-2">based on my proficiency:</span>
|
||||||
|
<div class="flex flex-wrap justify-center space-x-2 m-2">
|
||||||
|
<span class="chip variant-filled-primary text-lg">Proficient</span>
|
||||||
|
<span class="chip variant-outline-primary text-lg">Experienced</span>
|
||||||
|
<span class="chip variant-outline-tertiary text-lg">Limited Experience</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col items-center justify-center mx-auto space-x-2 space-y-2">
|
||||||
|
<IndividualSkills />
|
||||||
|
</div>
|
||||||
|
</Accordion>
|
||||||
|
</section>
|
|
@ -1,30 +1,51 @@
|
||||||
import { dev } from '$app/environment';
|
import { dev } from '$app/environment';
|
||||||
|
|
||||||
export const title = "Matt's Portfolio";
|
/* Website config */
|
||||||
export const description =
|
const siteTitle = 'The portfolio and blog of Matt Morin, projects, thoughts and ideas.';
|
||||||
|
const siteShortTitle = "Matt's Portfolio";
|
||||||
|
const description =
|
||||||
'I code, I think, I write. My thoughts go into the world of Free & Open Source Software, AI and philosophy of mind, Climate Change, Cybersecurity.';
|
'I code, I think, I write. My thoughts go into the world of Free & Open Source Software, AI and philosophy of mind, Climate Change, Cybersecurity.';
|
||||||
export const url = dev ? 'http://localhost:5174' : 'https://mattmor.in';
|
const siteUrl = dev ? 'http://localhost:5174' : 'https://mattmor.in';
|
||||||
export const author = 'Matt Morin';
|
const author = 'Matt Morin';
|
||||||
export const backgroundColor = '#111827';
|
const backgroundColor = '#111827';
|
||||||
export const themeColor = '#3b82f6';
|
const themeColor = '#3b82f6';
|
||||||
export const logo = '/Logo.png';
|
const logo = '/Logo.png';
|
||||||
export const keywords = 'Dev, FOSS, Nix, Philosopher, DevOps, Climate';
|
const keywords = 'Dev, FOSS, Nix, Philosopher, DevOps, Climate';
|
||||||
export const ogLanguage = 'en_US';
|
const ogLanguage = 'en_US';
|
||||||
export const siteLanguage = 'en-US';
|
const siteLanguage = 'en-US';
|
||||||
|
const defaultOgImage = '';
|
||||||
|
const defaultOgSquareImage = '';
|
||||||
|
const defaultTwitterImage = '';
|
||||||
|
|
||||||
// prettier-ignore
|
// prettier-ignore
|
||||||
export const socialLinks = [
|
const website = { siteTitle, siteShortTitle, description, siteUrl, author, backgroundColor, themeColor, logo, keywords, ogLanguage, siteLanguage, defaultOgImage, defaultOgSquareImage, defaultTwitterImage };
|
||||||
{ title: 'LinkedIn', href: 'https://linkedin.com/in/mattmor-in', icon: 'fa-brands fa-linkedin'},
|
|
||||||
{ title: 'Matrix', href: '', icon: './MatrixLogo' },
|
/* Social Nicknames */
|
||||||
{ title: 'Gitea', href: 'https://git.mattmor.in', icon: './GiteaLogo' },
|
const GHNick = 'matthieu42morin';
|
||||||
{ title: 'Mastodon', href: 'https://mastodon.social/@matt_mor', icon: 'fa-brands fa-mastodon'},
|
const LINick = 'mattmor-in';
|
||||||
{ title: 'RSS feed', href: '/blog/feed', icon: 'fa-regular fa-square-rss' },
|
const MatrixServer = '';
|
||||||
{ title: 'email', href: 'matt.b.morin@protonmail.com', icon: 'fa-regular mail'}
|
const MatrixNick = '';
|
||||||
];
|
const MastodonServer = 'mastodon.social';
|
||||||
|
const MastodonNick = '@matt_mor';
|
||||||
|
|
||||||
|
const socialNicks = { GHNick, LINick, MatrixNick, MastodonNick };
|
||||||
|
|
||||||
|
/* Social links */
|
||||||
|
const Email = 'matt.b.morin@protonmail.com';
|
||||||
|
const Github = `https://github.com/${GHNick}`;
|
||||||
|
const LinkedIn = `https://linkedin.com/in/${LINick}`;
|
||||||
|
const Matrix = `https://${MatrixServer}/${MatrixNick}`;
|
||||||
|
const Gitea = 'https://git.mattmor.in';
|
||||||
|
const Mastodon = `https://${MastodonServer}/${MastodonNick}`;
|
||||||
|
const RSS = '/blog/feed';
|
||||||
|
|
||||||
|
const socialLinks = { Email, Github, LinkedIn, Matrix, Gitea, Mastodon, RSS };
|
||||||
|
|
||||||
// Routes
|
// Routes
|
||||||
export const NavRoutes = [
|
const NavRoutes = [
|
||||||
{ title: 'Home', href: '/' },
|
{ title: 'Home', href: '/' },
|
||||||
{ title: 'Blog', href: '/blog' },
|
{ title: 'Blog', href: '/blog' },
|
||||||
{ title: 'Projects', href: '/projects' }
|
{ title: 'Projects', href: '/projects' }
|
||||||
];
|
];
|
||||||
|
|
||||||
|
export { website, socialNicks, socialLinks, NavRoutes };
|
||||||
|
|
|
@ -0,0 +1,9 @@
|
||||||
|
export const cookies = {
|
||||||
|
NECESSARY: 'mattmor-necessary',
|
||||||
|
ANALYTICAL: 'mattmor-analytical',
|
||||||
|
TARGETING: 'mattmor-targeting',
|
||||||
|
VISITED: 'mattmor-marketing-website-visited'
|
||||||
|
};
|
||||||
|
export const COPYRIGHT_ENTITY = '\u00a9'; // (c)
|
||||||
|
export const H_ELLIPSIS_ENTITY = '\u2026'; // ...
|
||||||
|
export const VERTICAL_LINE_ENTITY = '\u007c'; // |
|
|
@ -0,0 +1,12 @@
|
||||||
|
import { socialLinks } from './config';
|
||||||
|
|
||||||
|
const socials = [
|
||||||
|
{ title: 'Email', href: socialLinks.Email, icon: 'fa-solid fa-envelope' },
|
||||||
|
{ title: 'LinkedIn', href: socialLinks.LinkedIn, icon: 'fa-brands fa-linkedin' },
|
||||||
|
{ title: 'Matrix', href: socialLinks.Matrix, icon: 'MatrixLogo' },
|
||||||
|
{ title: 'Gitea', href: socialLinks.Gitea, icon: 'GiteaLogo' },
|
||||||
|
{ title: 'Mastodon', href: socialLinks.Mastodon, icon: 'fa-brands fa-mastodon' },
|
||||||
|
{ title: 'RSS feed', href: socialLinks.RSS, icon: 'fa-solid fa-square-rss' }
|
||||||
|
];
|
||||||
|
|
||||||
|
export default socials;
|
|
@ -1,17 +1,40 @@
|
||||||
import type { MarkdownMetadata } from '$content/types';
|
export interface MarkdownHeading {
|
||||||
|
title: string;
|
||||||
|
slug: string;
|
||||||
|
level: number;
|
||||||
|
children: MarkdownHeading[];
|
||||||
|
}
|
||||||
|
|
||||||
export type Tag = 'DevOps' | 'Philosophy' | 'Updates' | '';
|
export interface MarkdownMetadata {
|
||||||
|
headings: MarkdownHeading[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MdsvexImport<T extends MarkdownMetadata = MarkdownMetadata> {
|
||||||
|
// Technically not correct but needed to make language-tools happy
|
||||||
|
default: ConstructorOfATypedSvelteComponent;
|
||||||
|
metadata: T;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Tag = 'Projects' | 'Blog' | 'Updates' | '';
|
||||||
|
|
||||||
export interface Post extends MarkdownMetadata {
|
export interface Post extends MarkdownMetadata {
|
||||||
type?: 'Blog' | 'projects' | string;
|
slug: string;
|
||||||
date?: string;
|
title: string;
|
||||||
|
postTitle: string;
|
||||||
|
type: 'blog' | 'projects';
|
||||||
excerpt: string;
|
excerpt: string;
|
||||||
image: string;
|
datePublished: string;
|
||||||
slug?: string;
|
lastUpdated: string;
|
||||||
|
seoMetaDescription: string;
|
||||||
|
focusKeyphrase: string;
|
||||||
|
featuredImage: string;
|
||||||
|
featuredImageAlt: string;
|
||||||
|
imagePublicId: string;
|
||||||
href?: string;
|
href?: string;
|
||||||
tags?: Tag[];
|
tags?: Tag[];
|
||||||
subtitle?: string;
|
timeToRead: number;
|
||||||
teaserImage: string;
|
isAnExternalLink?: boolean;
|
||||||
title: string;
|
ogImage?: string;
|
||||||
isNotAnActualPost?: boolean;
|
ogSquareImage?: string;
|
||||||
|
twitterImage?: string;
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,96 @@
|
||||||
|
import type { Post } from '$lib/types/post';
|
||||||
|
import type { MdsvexImport, MarkdownMetadata } from '$lib/types/post';
|
||||||
|
import { error } from '@sveltejs/kit';
|
||||||
|
import readingTime from 'reading-time';
|
||||||
|
|
||||||
|
import { parseReadContent } from '$lib/utils/helpers';
|
||||||
|
|
||||||
|
const posts = import.meta.glob<MdsvexImport<Post & MarkdownMetadata>>('$content/blog/*.md', {
|
||||||
|
eager: true,
|
||||||
|
import: 'metadata'
|
||||||
|
});
|
||||||
|
|
||||||
|
export function listPosts() {
|
||||||
|
return parseReadContent(posts);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPost(slug: string) {
|
||||||
|
try {
|
||||||
|
const postKey = `$content/blog/${slug}.md`;
|
||||||
|
const post = posts[postKey];
|
||||||
|
|
||||||
|
if (!post) {
|
||||||
|
throw error(404, `Unable to find blog post "${slug}"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { imagePublicId } = data.metadata;
|
||||||
|
try {
|
||||||
|
return {
|
||||||
|
post: {
|
||||||
|
...data.metadata,
|
||||||
|
slug,
|
||||||
|
timeToRead: Math.ceil(readingTime(Component).minutes),
|
||||||
|
featuredImage: getImageUrl(imagePublicId, { width: 1200, height: 630 }),
|
||||||
|
ogImage: getImageUrl(imagePublicId, { width: 1200, height: 630 }),
|
||||||
|
ogSquareImage: getImageUrl(imagePublicId, { width: 400, height: 400 }),
|
||||||
|
twitterImage: getImageUrl(imagePublicId, { width: 1200, height: 630 })
|
||||||
|
},
|
||||||
|
Component: data.default
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
throw error(404, `Unable to return blog post "${slug}"`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching blog post:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// export async function getPost(slug: string) {
|
||||||
|
// try {
|
||||||
|
// const postPath = Object.keys(posts).find((path) => path.includes(`${slug}.md`));
|
||||||
|
// if (!postPath) {
|
||||||
|
// throw error(404, `Unable to find blog post "${slug}"`);
|
||||||
|
// }
|
||||||
|
// const data = await posts[postPath]();
|
||||||
|
|
||||||
|
// const { imagePublicId } = data.metadata;
|
||||||
|
|
||||||
|
// const data: MdsvexImport<Post> = await import.meta.glob(`$content/blog/${slug}.md`);
|
||||||
|
|
||||||
|
// return {
|
||||||
|
// post: {
|
||||||
|
// ...data.metadata,
|
||||||
|
// slug,
|
||||||
|
// timeToRead: Math.ceil(readingTime(data.default).minutes),
|
||||||
|
// featuredImage: getImageUrl(imagePublicId, { width: 1200, height: 630 }),
|
||||||
|
// ogImage: getImageUrl(imagePublicId, { width: 1200, height: 630 }),
|
||||||
|
// ogSquareImage: getImageUrl(imagePublicId, { width: 400, height: 400 }),
|
||||||
|
// twitterImage: getImageUrl(imagePublicId, { width: 1200, height: 630 })
|
||||||
|
// },
|
||||||
|
// Component: data.default
|
||||||
|
// };
|
||||||
|
// } catch (error) {
|
||||||
|
// console.error('Error fetching blog post:', error);
|
||||||
|
// //
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formats a date string into a human-readable format.
|
||||||
|
* @param date - The date string to format.
|
||||||
|
* @param locale - The locale to use when formatting the date. Defaults to 'en-US'.
|
||||||
|
* @returns A string representing the formatted date, or an error something is invalid.
|
||||||
|
*/
|
||||||
|
export const formatDate = (date: string, locale: Intl.Locale = 'en-US') => {
|
||||||
|
try {
|
||||||
|
const options: Intl.DateTimeFormatOptions = {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric'
|
||||||
|
};
|
||||||
|
return new Date(date).toLocaleDateString(locale, options);
|
||||||
|
} catch (e) {
|
||||||
|
return 'Invalid date string provided or something failed in formatting date.';
|
||||||
|
}
|
||||||
|
};
|
|
@ -1,4 +1,55 @@
|
||||||
import { readable } from 'svelte/store';
|
import { readable } from 'svelte/store';
|
||||||
|
import type { create_ssr_component } from 'svelte/internal';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses an object of data that has string keys and values of type `T` that have an optional `date` property.
|
||||||
|
* @param data - The object of data to parse.
|
||||||
|
* @param dateSort - A function that sorts an array of objects by their `date` property in descending order.
|
||||||
|
* @param mdPathToSlug - A function that converts an md file path to a slug.
|
||||||
|
* @returns An array of objects that have a `slug` property and the properties of the original data objects.
|
||||||
|
*/
|
||||||
|
export function parseReadContent<T extends { date?: string }>(data: Record<string, T>): T[] {
|
||||||
|
return Object.entries(data)
|
||||||
|
.map(([file, data]) => ({
|
||||||
|
slug: mdPathToSlug(file),
|
||||||
|
...data
|
||||||
|
}))
|
||||||
|
.sort(dateSort);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sorts an array of objects by their `date` property in descending order.
|
||||||
|
* @param a - The first object to compare.
|
||||||
|
* @param b - The second object to compare.
|
||||||
|
* @returns A number that represents the difference between the parsed dates of the two objects.
|
||||||
|
*/
|
||||||
|
export function dateSort<T extends { date?: string }>(a: T, b: T): number {
|
||||||
|
return Date.parse(b.date) - Date.parse(a.date);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders an mdsvex component and returns the resulting HTML string.
|
||||||
|
* @param component - The mdsvex component to render.
|
||||||
|
* @returns The HTML string that was generated by rendering the component.
|
||||||
|
* @throws An error if the `render` property of the component is not a function.
|
||||||
|
*/
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
export function renderMdsvexComponent(component: any): string {
|
||||||
|
if (typeof component['render'] != 'function') {
|
||||||
|
throw new Error("Unable to render something that isn't a mdsvex component");
|
||||||
|
}
|
||||||
|
|
||||||
|
return (component as ReturnType<typeof create_ssr_component>).render().html;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts an md file path to a slug by removing the last segment of the path and the `.md` extension.
|
||||||
|
* @param path - The path of the md file.
|
||||||
|
* @returns The slug of the md file.
|
||||||
|
*/
|
||||||
|
export function mdPathToSlug(path: string) {
|
||||||
|
return path.split('/').at(-1).slice(0, -3);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Determines if the current timezone is between 0 and +3 hours UTC.
|
* Determines if the current timezone is between 0 and +3 hours UTC.
|
||||||
|
@ -105,3 +156,8 @@ export const scrollIntoView = (selector: string) => {
|
||||||
behavior: mediaQuery.matches ? 'auto' : 'smooth'
|
behavior: mediaQuery.matches ? 'auto' : 'smooth'
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const generateURL = (href?: string, type: string, slug?: string) => {
|
||||||
|
if (href) return href;
|
||||||
|
return `/${type}/${slug}`;
|
||||||
|
};
|
||||||
|
|
|
@ -0,0 +1,33 @@
|
||||||
|
import { v2 as cloudinary } from 'cloudinary';
|
||||||
|
import { PUBLIC_CLOUDINARY_NAME } from '$env/static/public';
|
||||||
|
import { CLOUDINARY_API_KEY, CLOUDINARY_API_SECRET } from '$env/static/private';
|
||||||
|
|
||||||
|
cloudinary.config({
|
||||||
|
cloud_name: PUBLIC_CLOUDINARY_NAME,
|
||||||
|
api_key: CLOUDINARY_API_KEY,
|
||||||
|
api_secret: CLOUDINARY_API_SECRET
|
||||||
|
});
|
||||||
|
|
||||||
|
type ImageTransformationOptions = {
|
||||||
|
width?: number;
|
||||||
|
height?: number;
|
||||||
|
crop?: string;
|
||||||
|
format?: string;
|
||||||
|
quality?: string | number;
|
||||||
|
[key: string]: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getImageUrl(publicId: string, options: ImageTransformationOptions = {}): string {
|
||||||
|
return cloudinary.url(publicId, {
|
||||||
|
...options,
|
||||||
|
crop: options.crop || 'fill',
|
||||||
|
format: options.format || 'auto',
|
||||||
|
quality: options.quality || 'auto'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getImagePublicId = (imageKey: string) => {
|
||||||
|
return images[imageKey] || '';
|
||||||
|
};
|
||||||
|
|
||||||
|
export default cloudinary;
|
|
@ -1,7 +1,6 @@
|
||||||
import type { MdsvexImport } from '$content/types';
|
import type { MdsvexImport, MarkdownMetadata } from '$lib/types/post';
|
||||||
import type { MarkdownMetadata } from '$content/types';
|
|
||||||
import type { Project } from '$lib/types/projects';
|
import type { Project } from '$lib/types/projects';
|
||||||
import { parseReadContent } from './utils';
|
import { parseReadContent } from '$lib/utils/blog';
|
||||||
import { error } from '@sveltejs/kit';
|
import { error } from '@sveltejs/kit';
|
||||||
|
|
||||||
/**
|
/**
|
|
@ -97,19 +97,27 @@
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
//mode based on system preference
|
||||||
|
import { autoModeWatcher } from '@skeletonlabs/skeleton';
|
||||||
|
|
||||||
// Highlight JS
|
// Highlight JS
|
||||||
import hljs from 'highlight.js';
|
// import hljs from 'highlight.js';
|
||||||
import 'highlight.js/styles/nord.css';
|
// import 'highlight.js/styles/nord.css';
|
||||||
import { storeHighlightJs } from '@skeletonlabs/skeleton';
|
// import { storeHighlightJs } from '@skeletonlabs/skeleton';
|
||||||
import { browser } from '$app/environment';
|
import { browser } from '$app/environment';
|
||||||
storeHighlightJs.set(hljs);
|
// storeHighlightJs.set(hljs);
|
||||||
|
import '@kitbook/mdsvex-shiki-twoslash/shiki-twoslash.css';
|
||||||
|
// import '@fontsource/fira-mono';
|
||||||
|
// import '@fontsource/cooper-hewitt';
|
||||||
|
// import '$lib/assets/global.css';
|
||||||
|
// import '$lib/assets/code.css';
|
||||||
|
|
||||||
|
// SEO Meta tags taken from my implementation of KKosmetickySalon
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
<link
|
<link rel="canonical" />
|
||||||
rel="canonical"
|
<!-- {@html '<script>(' + autoModeWatcher.toString() + ')();</script>'} -->
|
||||||
href={removeTrailingSlash(`https://www.mattmor.in${$page.url.pathname}`)}
|
|
||||||
/>
|
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<!-- <Analytics />
|
<!-- <Analytics />
|
||||||
|
|
|
@ -1,48 +1,27 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import SkillContainer from '$lib/components/SkillContainer.svelte';
|
import SkillContainer from '$lib/components/skills/SkillContainer.svelte';
|
||||||
import HeroSection from '$lib/components/home/HeroSection.svelte';
|
import HeroSection from '$lib/components/home/HeroSection.svelte';
|
||||||
import QuickCards from '$lib/components/home/QuickCards.svelte';
|
import QuickCards from '$lib/components/home/QuickCards.svelte';
|
||||||
|
|
||||||
|
import { website } from '$lib/config';
|
||||||
import * as conf from '$lib/config';
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
<title>Matt Morin</title>
|
<title>Matt Morin</title>
|
||||||
<meta property="og:type" content="website" />
|
<meta property="og:type" content="website" />
|
||||||
<meta name="description" content={conf.description} />
|
<meta name="description" content={website.description} />
|
||||||
<meta property="og:title" content={conf.title} />
|
<meta property="og:title" content={website.siteTitle} />
|
||||||
<meta property="og:type" content="article" />
|
<meta property="og:type" content="article" />
|
||||||
<meta property="og:description" content={conf.description} />
|
<meta property="og:description" content={website.description} />
|
||||||
<meta property="og:url" content={conf.url} />
|
<meta property="og:url" content={website.url} />
|
||||||
<meta property="og:image" content="/images/profile-pic.png" />
|
<meta property="og:image" content="/images/profile-pic.png" />
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<div class="mt-24 container h-full mx-auto flex-col justify-center items-center md:w-3/4">
|
<div class="mt-24 container h-full mx-auto flex-col justify-center items-center md:w-3/4 space-y-8">
|
||||||
<!-- <div class="space-y-10 mt-4 text-center flex flex-col items-center">
|
|
||||||
<h1 class="h1">I make the wheels turn.</h1>
|
|
||||||
|
|
||||||
<figure>
|
|
||||||
<section class="img-bg" />
|
|
||||||
<img
|
|
||||||
src="/images/profile-pic.png"
|
|
||||||
class="w-8 h-8 md:h-[200px] md:w-[200px]"
|
|
||||||
alt="Profile picture"
|
|
||||||
/>
|
|
||||||
</figure>
|
|
||||||
|
|
||||||
|
|
||||||
<img
|
|
||||||
src="/animations/infinity-loop-icon.svg"
|
|
||||||
alt="Icon"
|
|
||||||
class="w-16 md:w-32 lg:w-48 h-full rounded-full"
|
|
||||||
/>
|
|
||||||
<h2 class="h2">My github contributions</h2>
|
|
||||||
</div> -->
|
|
||||||
|
|
||||||
<HeroSection />
|
<HeroSection />
|
||||||
|
|
||||||
<SkillContainer />
|
<SkillContainer />
|
||||||
|
<QuickCards />
|
||||||
|
|
||||||
|
<SkillContainer />
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -0,0 +1,95 @@
|
||||||
|
import { json } from '@sveltejs/kit';
|
||||||
|
import type { Post } from '$lib/types/post';
|
||||||
|
import type { MdsvexImport } from '$lib/types/post';
|
||||||
|
import { error } from '@sveltejs/kit';
|
||||||
|
import readingTime from 'reading-time';
|
||||||
|
import { getImageUrl } from '$lib/utils/images';
|
||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
|
||||||
|
const getPosts = async () => {
|
||||||
|
let posts: Post[] = [];
|
||||||
|
|
||||||
|
const paths = import.meta.glob<Post & MdsvexImport>('$content/blog/*.md', {
|
||||||
|
eager: true
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const path in paths) {
|
||||||
|
const file = paths[path];
|
||||||
|
const slug = path.split('/').at(-1)?.replace('.md', '');
|
||||||
|
|
||||||
|
if (file && typeof file === 'object' && 'metadata' in file && slug) {
|
||||||
|
const metadata = file.metadata as Omit<Post, 'slug'>;
|
||||||
|
const { imagePublicId } = metadata;
|
||||||
|
|
||||||
|
const post = {
|
||||||
|
...metadata,
|
||||||
|
slug,
|
||||||
|
readingTime,
|
||||||
|
featuredImage: getImageUrl(imagePublicId, { width: 1200, height: 630 }),
|
||||||
|
ogImage: getImageUrl(imagePublicId, { width: 1200, height: 630 }),
|
||||||
|
ogSquareImage: getImageUrl(imagePublicId, { width: 400, height: 400 }),
|
||||||
|
twitterImage: getImageUrl(imagePublicId, { width: 1200, height: 630 })
|
||||||
|
} satisfies Post;
|
||||||
|
post.datePublished && posts.push(post);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
posts = posts.sort(
|
||||||
|
(first, second) =>
|
||||||
|
new Date(second.datePublished).getTime() - new Date(first.datePublished).getTime()
|
||||||
|
);
|
||||||
|
|
||||||
|
// Add SEO metadata
|
||||||
|
// posts = posts.map((post) => ({
|
||||||
|
// ...post,
|
||||||
|
// seoTitle: `${post.title} | ${defaultMeta.siteTitle}`,
|
||||||
|
// seoDescription: post.description || defaultMeta.defaultDescription,
|
||||||
|
// seoImage: post.ogImage || defaultMeta.ogImage,
|
||||||
|
// seoSquareImage: post.ogSquareImage || defaultMeta.ogSquareImage,
|
||||||
|
// seoTwitterImage: post.twitterImage || defaultMeta.twitterImage
|
||||||
|
// }));
|
||||||
|
|
||||||
|
// Add SEO metadata
|
||||||
|
// posts = posts.map((post) => ({
|
||||||
|
// ...post,
|
||||||
|
// seo: {
|
||||||
|
// title: post.postTitle,
|
||||||
|
// description: post.seoMetaDescription,
|
||||||
|
// openGraph: {
|
||||||
|
// title: post.postTitle,
|
||||||
|
// description: post.seoMetaDescription,
|
||||||
|
// type: post.type,
|
||||||
|
// url: `https://mattmor.in/blog/${post.slug}`,
|
||||||
|
// images: [
|
||||||
|
// {
|
||||||
|
// url: post.ogImage,
|
||||||
|
// width: 1200,
|
||||||
|
// height: 630,
|
||||||
|
// alt: post.featuredImageAlt
|
||||||
|
// },
|
||||||
|
// {
|
||||||
|
// url: post.ogSquareImage,
|
||||||
|
// width: 400,
|
||||||
|
// height: 400,
|
||||||
|
// alt: post.featuredImageAlt
|
||||||
|
// }
|
||||||
|
// ]
|
||||||
|
// },
|
||||||
|
// twitter: {
|
||||||
|
// card: 'summary_large_image',
|
||||||
|
// site: '@yourtwitterhandle',
|
||||||
|
// title: post.postTitle,
|
||||||
|
// description: post.seoMetaDescription,
|
||||||
|
// image: post.twitterImage,
|
||||||
|
// imageAlt: post.featuredImageAlt
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// },
|
||||||
|
|
||||||
|
return posts;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const GET: RequestHandler = async () => {
|
||||||
|
const posts = await getPosts();
|
||||||
|
const projects = await getProjects();
|
||||||
|
return json(posts);
|
||||||
|
};
|
|
@ -1,7 +1,7 @@
|
||||||
import { listPosts } from '$content/blog';
|
import type { PageLoad } from './$types';
|
||||||
|
|
||||||
export const load = async () => {
|
export const load = async ({ fetch }) => {
|
||||||
return {
|
const response = await fetch('api/posts');
|
||||||
posts: listPosts()
|
const posts: Post[] = await response.json();
|
||||||
};
|
return { posts };
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,60 +1,73 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import PostPreview from '$lib/components/blog/PostPreview.svelte';
|
import ogSquareImageSrc from '$lib/assets/home/home-open-graph-square.jpg';
|
||||||
import CategoryFilter from '$lib/components/blog/CategoryFilter.svelte';
|
import ogImageSrc from '$lib/assets/home/home-open-graph.jpg';
|
||||||
|
import twitterImageSrc from '$lib/assets/home/home-twitter.jpg';
|
||||||
|
import featuredImageSrc from '$lib/assets/home/home.jpg';
|
||||||
|
import Feed from '$lib/components/blog/Feed.svelte';
|
||||||
|
|
||||||
|
import SEO from '$lib/components/SEO/index.svelte';
|
||||||
|
import { website } from '$lib/config';
|
||||||
import type { PageData } from './$types';
|
import type { PageData } from './$types';
|
||||||
import type { Tag } from '$lib/types/post';
|
|
||||||
import { page } from '$app/stores';
|
|
||||||
import { onMount } from 'svelte';
|
|
||||||
|
|
||||||
export let data: PageData;
|
export let data: PageData;
|
||||||
|
// console.log(data);
|
||||||
|
|
||||||
let filter: Tag | null = null;
|
const { author, siteUrl } = website;
|
||||||
|
|
||||||
$: posts = data.posts.filter((post) => (filter ? post.tags?.includes(filter) : true));
|
let title = 'Home';
|
||||||
|
let tags = ['blog', 'home'];
|
||||||
onMount(() => {
|
const breadcrumbs = [
|
||||||
const tagParam = $page.url.searchParams.get('tag');
|
{
|
||||||
|
name: 'Home',
|
||||||
if (!filter && typeof tagParam == 'string') {
|
slug: ''
|
||||||
filter = tagParam as Tag;
|
|
||||||
}
|
}
|
||||||
});
|
];
|
||||||
|
let metadescription =
|
||||||
|
'SvelteKit MDsvex Blog Starter - starter code by Rodney Lab to help you get going on your next blog site';
|
||||||
|
const featuredImageAlt =
|
||||||
|
'picture of a person with long, curly hair, wearing a red had taking a picture with an analogue camera';
|
||||||
|
const featuredImage = {
|
||||||
|
siteUrl: featuredImageSrc,
|
||||||
|
alt: featuredImageAlt,
|
||||||
|
width: 672,
|
||||||
|
height: 448,
|
||||||
|
caption: 'Home page'
|
||||||
|
};
|
||||||
|
const ogImage = {
|
||||||
|
siteUrl: ogImageSrc,
|
||||||
|
alt: featuredImageAlt
|
||||||
|
};
|
||||||
|
const ogSquareImage = {
|
||||||
|
siteUrl: ogSquareImageSrc,
|
||||||
|
alt: featuredImageAlt
|
||||||
|
};
|
||||||
|
|
||||||
const displayAmount = 12;
|
const twitterImage = {
|
||||||
|
siteUrl: twitterImageSrc,
|
||||||
|
alt: featuredImageAlt
|
||||||
|
};
|
||||||
|
const entityMeta = {
|
||||||
|
siteUrl: `${siteUrl}/`,
|
||||||
|
faviconWidth: 512,
|
||||||
|
faviconHeight: 512,
|
||||||
|
caption: author
|
||||||
|
};
|
||||||
|
const seoProps = {
|
||||||
|
title,
|
||||||
|
slug: '',
|
||||||
|
entityMeta,
|
||||||
|
datePublished: '2021-07-07T14:19:33.000+0100',
|
||||||
|
lastUpdated: '2021-07-07T14:19:33.000+0100',
|
||||||
|
breadcrumbs,
|
||||||
|
metadescription,
|
||||||
|
featuredImage,
|
||||||
|
ogImage,
|
||||||
|
ogSquareImage,
|
||||||
|
twitterImage,
|
||||||
|
tags
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div>
|
<SEO {...seoProps} />
|
||||||
<div class="space-y-8">
|
|
||||||
<header class="flex flex-col justify-center items-center">
|
|
||||||
<h1 class="h1 m-4">Blog</h1>
|
|
||||||
<CategoryFilter class="mb-2 md:mb-4" bind:selected={filter} />
|
|
||||||
</header>
|
|
||||||
<div
|
|
||||||
class="grid m-auto max-w-7xl w-full gap-6 grid-cols-none justify-center md:grid-cols-2 lg:grid-cols-3"
|
|
||||||
>
|
|
||||||
{#each posts.slice(0, displayAmount) as post}
|
|
||||||
<div class="flex justify-center min-w-[20rem] max-w-sm">
|
|
||||||
<PostPreview {post} type="blog" isMostRecent />
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if posts.slice(displayAmount).length > 0}
|
<Feed {data} />
|
||||||
<div>
|
|
||||||
<h2 class="mb-4 text-center">Previous posts</h2>
|
|
||||||
<div
|
|
||||||
class="previous grid m-auto max-w-7xl w-full gap-6 grid-cols-none justify-center md:grid-cols-2 lg:grid-cols-3"
|
|
||||||
>
|
|
||||||
{#each posts.slice(displayAmount) as post}
|
|
||||||
<div class="flex justify-center min-w-[20rem] max-w-sm">
|
|
||||||
<PostPreview {post} type="blog" />
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style lang="postcss">
|
|
||||||
</style>
|
|
||||||
|
|
|
@ -1,27 +0,0 @@
|
||||||
import { listPosts } from '$content/blog';
|
|
||||||
import { error } from '@sveltejs/kit';
|
|
||||||
|
|
||||||
function shuffle<T>(array: T[]) {
|
|
||||||
for (let i = array.length - 1; i > 0; i--) {
|
|
||||||
const j = Math.floor(Math.random() * (i + 1));
|
|
||||||
[array[i], array[j]] = [array[j], array[i]];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function load({ params }) {
|
|
||||||
const posts = listPosts();
|
|
||||||
const currentPost = posts.find((post) => post.slug == params.slug);
|
|
||||||
|
|
||||||
if (!currentPost) {
|
|
||||||
throw error(404, `Unable to find blog post "${params.slug}"`);
|
|
||||||
}
|
|
||||||
|
|
||||||
shuffle(posts);
|
|
||||||
|
|
||||||
return {
|
|
||||||
featuredPosts: posts
|
|
||||||
.filter((post) => post.slug != params.slug)
|
|
||||||
.filter((p) => p.tags?.some((t) => currentPost.tags?.includes(t)))
|
|
||||||
.slice(0, 3)
|
|
||||||
};
|
|
||||||
}
|
|
|
@ -1,16 +1,10 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import BlogContentLayout from '$lib/components/blog/BlogContentLayout.svelte';
|
import PostLayout from '$lib/components/blog/PostLayout.svelte';
|
||||||
import type { PageData } from './$types';
|
import type { PageData } from '../$types';
|
||||||
|
|
||||||
export let data: PageData;
|
export let data: PageData;
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<!-- <svelte:head>
|
<PostLayout post={data.meta}>
|
||||||
<title>{data.post.title}</title>
|
<svelte:component this={data.content} />
|
||||||
<meta property="og:type" content="article" />
|
</PostLayout>
|
||||||
<meta property="og:title" content={data.post.title} />
|
|
||||||
</svelte:head> -->
|
|
||||||
|
|
||||||
<BlogContentLayout post={data.post}>
|
|
||||||
<svelte:component this={data.Component} />
|
|
||||||
</BlogContentLayout>
|
|
||||||
|
|
|
@ -1,14 +1,19 @@
|
||||||
import { getPost, listPosts } from '$content/blog';
|
import type { PageServerLoad } from './$types';
|
||||||
import type { PageLoad } from './$types';
|
|
||||||
|
|
||||||
export const entries = async () => {
|
import { error } from '@sveltejs/kit';
|
||||||
const posts = await listPosts();
|
|
||||||
return posts
|
|
||||||
.filter((post) => post.slug !== undefined)
|
|
||||||
.map((post) => ({ slug: post.slug as string }));
|
|
||||||
};
|
|
||||||
|
|
||||||
export const load: PageLoad = async ({ params, parent }) => {
|
export const load: PageServerLoad = async ({ params }) => {
|
||||||
await parent();
|
try {
|
||||||
return await getPost(params.slug);
|
const post = await import(`../../../content/blog/${params.slug}.md`);
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: post.default,
|
||||||
|
meta: post.metadata
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
if (err.status === 404) {
|
||||||
|
error(404, `Could not find ${params.slug}`);
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import { listPosts } from '$content/blog';
|
import { listPosts } from '$lib/utils/blog';
|
||||||
import RSS from 'rss';
|
import RSS from 'rss';
|
||||||
import type { Post } from '$lib/types/post';
|
import type { Post } from '$lib/types/post';
|
||||||
|
|
||||||
|
|
|
@ -1 +0,0 @@
|
||||||
<p>There is nothing to see yet</p>
|
|
|
@ -1,7 +1,7 @@
|
||||||
import { listProjects } from '$content/projects';
|
import type { PageLoad } from './$types';
|
||||||
|
|
||||||
export const load = () => {
|
export const load = async ({ fetch }) => {
|
||||||
return {
|
const response = await fetch('api/posts');
|
||||||
projects: listProjects()
|
const projects: Projects[] = await response.json();
|
||||||
};
|
return { projects };
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { PageData } from './$types';
|
import type { PageData } from './$types';
|
||||||
import PostPreview from '$lib/components/blog/PostPreview.svelte';
|
import PostPreview from '$lib/components/blog/Preview.svelte';
|
||||||
|
|
||||||
export let data: PageData;
|
export let data: PageData;
|
||||||
</script>
|
</script>
|
||||||
|
|
|
@ -1,27 +0,0 @@
|
||||||
import { listProjects } from '$content/projects';
|
|
||||||
import { error } from '@sveltejs/kit';
|
|
||||||
|
|
||||||
function shuffle<T>(array: T[]) {
|
|
||||||
for (let i = array.length - 1; i > 0; i--) {
|
|
||||||
const j = Math.floor(Math.random() * (i + 1));
|
|
||||||
[array[i], array[j]] = [array[j], array[i]];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function load({ params }) {
|
|
||||||
const posts = listProjects();
|
|
||||||
const currentPost = posts.find((post) => post.slug == params.slug);
|
|
||||||
|
|
||||||
if (!currentPost) {
|
|
||||||
throw error(404, `Unable to find blog post "${params.slug}"`);
|
|
||||||
}
|
|
||||||
|
|
||||||
shuffle(posts);
|
|
||||||
|
|
||||||
return {
|
|
||||||
featuredPosts: posts
|
|
||||||
.filter((post) => post.slug != params.slug)
|
|
||||||
.filter((p) => p.tags?.some((t) => currentPost.tags?.includes(t)))
|
|
||||||
.slice(0, 3)
|
|
||||||
};
|
|
||||||
}
|
|
|
@ -1,5 +1,5 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import ProjectsContentLayout from '$lib/components/projects/ProjectsContentLayout.svelte';
|
import ProjectsContentLayout from '$lib/components/blog/PostLayout.svelte';
|
||||||
import type { PageData } from './$types';
|
import type { PageData } from './$types';
|
||||||
|
|
||||||
export let data: PageData;
|
export let data: PageData;
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import { getProject, listProjects } from '$content/projects';
|
import { getProject, listProjects } from '$lib/utils/projects';
|
||||||
import type { PageLoad } from './$types';
|
import type { PageLoad } from './$types';
|
||||||
|
|
||||||
export const entries = () => listProjects().map((post) => ({ slug: post.slug }));
|
export const entries = () => listProjects().map((post) => ({ slug: post.slug }));
|
||||||
|
|
|
@ -1,437 +0,0 @@
|
||||||
import { test, expect, type Page } from '@playwright/test';
|
|
||||||
|
|
||||||
test.beforeEach(async ({ page }) => {
|
|
||||||
await page.goto('https://demo.playwright.dev/todomvc');
|
|
||||||
});
|
|
||||||
|
|
||||||
const TODO_ITEMS = [
|
|
||||||
'buy some cheese',
|
|
||||||
'feed the cat',
|
|
||||||
'book a doctors appointment'
|
|
||||||
];
|
|
||||||
|
|
||||||
test.describe('New Todo', () => {
|
|
||||||
test('should allow me to add todo items', async ({ page }) => {
|
|
||||||
// create a new todo locator
|
|
||||||
const newTodo = page.getByPlaceholder('What needs to be done?');
|
|
||||||
|
|
||||||
// Create 1st todo.
|
|
||||||
await newTodo.fill(TODO_ITEMS[0]);
|
|
||||||
await newTodo.press('Enter');
|
|
||||||
|
|
||||||
// Make sure the list only has one todo item.
|
|
||||||
await expect(page.getByTestId('todo-title')).toHaveText([
|
|
||||||
TODO_ITEMS[0]
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Create 2nd todo.
|
|
||||||
await newTodo.fill(TODO_ITEMS[1]);
|
|
||||||
await newTodo.press('Enter');
|
|
||||||
|
|
||||||
// Make sure the list now has two todo items.
|
|
||||||
await expect(page.getByTestId('todo-title')).toHaveText([
|
|
||||||
TODO_ITEMS[0],
|
|
||||||
TODO_ITEMS[1]
|
|
||||||
]);
|
|
||||||
|
|
||||||
await checkNumberOfTodosInLocalStorage(page, 2);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should clear text input field when an item is added', async ({ page }) => {
|
|
||||||
// create a new todo locator
|
|
||||||
const newTodo = page.getByPlaceholder('What needs to be done?');
|
|
||||||
|
|
||||||
// Create one todo item.
|
|
||||||
await newTodo.fill(TODO_ITEMS[0]);
|
|
||||||
await newTodo.press('Enter');
|
|
||||||
|
|
||||||
// Check that input is empty.
|
|
||||||
await expect(newTodo).toBeEmpty();
|
|
||||||
await checkNumberOfTodosInLocalStorage(page, 1);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should append new items to the bottom of the list', async ({ page }) => {
|
|
||||||
// Create 3 items.
|
|
||||||
await createDefaultTodos(page);
|
|
||||||
|
|
||||||
// create a todo count locator
|
|
||||||
const todoCount = page.getByTestId('todo-count')
|
|
||||||
|
|
||||||
// Check test using different methods.
|
|
||||||
await expect(page.getByText('3 items left')).toBeVisible();
|
|
||||||
await expect(todoCount).toHaveText('3 items left');
|
|
||||||
await expect(todoCount).toContainText('3');
|
|
||||||
await expect(todoCount).toHaveText(/3/);
|
|
||||||
|
|
||||||
// Check all items in one call.
|
|
||||||
await expect(page.getByTestId('todo-title')).toHaveText(TODO_ITEMS);
|
|
||||||
await checkNumberOfTodosInLocalStorage(page, 3);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test.describe('Mark all as completed', () => {
|
|
||||||
test.beforeEach(async ({ page }) => {
|
|
||||||
await createDefaultTodos(page);
|
|
||||||
await checkNumberOfTodosInLocalStorage(page, 3);
|
|
||||||
});
|
|
||||||
|
|
||||||
test.afterEach(async ({ page }) => {
|
|
||||||
await checkNumberOfTodosInLocalStorage(page, 3);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should allow me to mark all items as completed', async ({ page }) => {
|
|
||||||
// Complete all todos.
|
|
||||||
await page.getByLabel('Mark all as complete').check();
|
|
||||||
|
|
||||||
// Ensure all todos have 'completed' class.
|
|
||||||
await expect(page.getByTestId('todo-item')).toHaveClass(['completed', 'completed', 'completed']);
|
|
||||||
await checkNumberOfCompletedTodosInLocalStorage(page, 3);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should allow me to clear the complete state of all items', async ({ page }) => {
|
|
||||||
const toggleAll = page.getByLabel('Mark all as complete');
|
|
||||||
// Check and then immediately uncheck.
|
|
||||||
await toggleAll.check();
|
|
||||||
await toggleAll.uncheck();
|
|
||||||
|
|
||||||
// Should be no completed classes.
|
|
||||||
await expect(page.getByTestId('todo-item')).toHaveClass(['', '', '']);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('complete all checkbox should update state when items are completed / cleared', async ({ page }) => {
|
|
||||||
const toggleAll = page.getByLabel('Mark all as complete');
|
|
||||||
await toggleAll.check();
|
|
||||||
await expect(toggleAll).toBeChecked();
|
|
||||||
await checkNumberOfCompletedTodosInLocalStorage(page, 3);
|
|
||||||
|
|
||||||
// Uncheck first todo.
|
|
||||||
const firstTodo = page.getByTestId('todo-item').nth(0);
|
|
||||||
await firstTodo.getByRole('checkbox').uncheck();
|
|
||||||
|
|
||||||
// Reuse toggleAll locator and make sure its not checked.
|
|
||||||
await expect(toggleAll).not.toBeChecked();
|
|
||||||
|
|
||||||
await firstTodo.getByRole('checkbox').check();
|
|
||||||
await checkNumberOfCompletedTodosInLocalStorage(page, 3);
|
|
||||||
|
|
||||||
// Assert the toggle all is checked again.
|
|
||||||
await expect(toggleAll).toBeChecked();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test.describe('Item', () => {
|
|
||||||
|
|
||||||
test('should allow me to mark items as complete', async ({ page }) => {
|
|
||||||
// create a new todo locator
|
|
||||||
const newTodo = page.getByPlaceholder('What needs to be done?');
|
|
||||||
|
|
||||||
// Create two items.
|
|
||||||
for (const item of TODO_ITEMS.slice(0, 2)) {
|
|
||||||
await newTodo.fill(item);
|
|
||||||
await newTodo.press('Enter');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check first item.
|
|
||||||
const firstTodo = page.getByTestId('todo-item').nth(0);
|
|
||||||
await firstTodo.getByRole('checkbox').check();
|
|
||||||
await expect(firstTodo).toHaveClass('completed');
|
|
||||||
|
|
||||||
// Check second item.
|
|
||||||
const secondTodo = page.getByTestId('todo-item').nth(1);
|
|
||||||
await expect(secondTodo).not.toHaveClass('completed');
|
|
||||||
await secondTodo.getByRole('checkbox').check();
|
|
||||||
|
|
||||||
// Assert completed class.
|
|
||||||
await expect(firstTodo).toHaveClass('completed');
|
|
||||||
await expect(secondTodo).toHaveClass('completed');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should allow me to un-mark items as complete', async ({ page }) => {
|
|
||||||
// create a new todo locator
|
|
||||||
const newTodo = page.getByPlaceholder('What needs to be done?');
|
|
||||||
|
|
||||||
// Create two items.
|
|
||||||
for (const item of TODO_ITEMS.slice(0, 2)) {
|
|
||||||
await newTodo.fill(item);
|
|
||||||
await newTodo.press('Enter');
|
|
||||||
}
|
|
||||||
|
|
||||||
const firstTodo = page.getByTestId('todo-item').nth(0);
|
|
||||||
const secondTodo = page.getByTestId('todo-item').nth(1);
|
|
||||||
const firstTodoCheckbox = firstTodo.getByRole('checkbox');
|
|
||||||
|
|
||||||
await firstTodoCheckbox.check();
|
|
||||||
await expect(firstTodo).toHaveClass('completed');
|
|
||||||
await expect(secondTodo).not.toHaveClass('completed');
|
|
||||||
await checkNumberOfCompletedTodosInLocalStorage(page, 1);
|
|
||||||
|
|
||||||
await firstTodoCheckbox.uncheck();
|
|
||||||
await expect(firstTodo).not.toHaveClass('completed');
|
|
||||||
await expect(secondTodo).not.toHaveClass('completed');
|
|
||||||
await checkNumberOfCompletedTodosInLocalStorage(page, 0);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should allow me to edit an item', async ({ page }) => {
|
|
||||||
await createDefaultTodos(page);
|
|
||||||
|
|
||||||
const todoItems = page.getByTestId('todo-item');
|
|
||||||
const secondTodo = todoItems.nth(1);
|
|
||||||
await secondTodo.dblclick();
|
|
||||||
await expect(secondTodo.getByRole('textbox', { name: 'Edit' })).toHaveValue(TODO_ITEMS[1]);
|
|
||||||
await secondTodo.getByRole('textbox', { name: 'Edit' }).fill('buy some sausages');
|
|
||||||
await secondTodo.getByRole('textbox', { name: 'Edit' }).press('Enter');
|
|
||||||
|
|
||||||
// Explicitly assert the new text value.
|
|
||||||
await expect(todoItems).toHaveText([
|
|
||||||
TODO_ITEMS[0],
|
|
||||||
'buy some sausages',
|
|
||||||
TODO_ITEMS[2]
|
|
||||||
]);
|
|
||||||
await checkTodosInLocalStorage(page, 'buy some sausages');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test.describe('Editing', () => {
|
|
||||||
test.beforeEach(async ({ page }) => {
|
|
||||||
await createDefaultTodos(page);
|
|
||||||
await checkNumberOfTodosInLocalStorage(page, 3);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should hide other controls when editing', async ({ page }) => {
|
|
||||||
const todoItem = page.getByTestId('todo-item').nth(1);
|
|
||||||
await todoItem.dblclick();
|
|
||||||
await expect(todoItem.getByRole('checkbox')).not.toBeVisible();
|
|
||||||
await expect(todoItem.locator('label', {
|
|
||||||
hasText: TODO_ITEMS[1],
|
|
||||||
})).not.toBeVisible();
|
|
||||||
await checkNumberOfTodosInLocalStorage(page, 3);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should save edits on blur', async ({ page }) => {
|
|
||||||
const todoItems = page.getByTestId('todo-item');
|
|
||||||
await todoItems.nth(1).dblclick();
|
|
||||||
await todoItems.nth(1).getByRole('textbox', { name: 'Edit' }).fill('buy some sausages');
|
|
||||||
await todoItems.nth(1).getByRole('textbox', { name: 'Edit' }).dispatchEvent('blur');
|
|
||||||
|
|
||||||
await expect(todoItems).toHaveText([
|
|
||||||
TODO_ITEMS[0],
|
|
||||||
'buy some sausages',
|
|
||||||
TODO_ITEMS[2],
|
|
||||||
]);
|
|
||||||
await checkTodosInLocalStorage(page, 'buy some sausages');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should trim entered text', async ({ page }) => {
|
|
||||||
const todoItems = page.getByTestId('todo-item');
|
|
||||||
await todoItems.nth(1).dblclick();
|
|
||||||
await todoItems.nth(1).getByRole('textbox', { name: 'Edit' }).fill(' buy some sausages ');
|
|
||||||
await todoItems.nth(1).getByRole('textbox', { name: 'Edit' }).press('Enter');
|
|
||||||
|
|
||||||
await expect(todoItems).toHaveText([
|
|
||||||
TODO_ITEMS[0],
|
|
||||||
'buy some sausages',
|
|
||||||
TODO_ITEMS[2],
|
|
||||||
]);
|
|
||||||
await checkTodosInLocalStorage(page, 'buy some sausages');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should remove the item if an empty text string was entered', async ({ page }) => {
|
|
||||||
const todoItems = page.getByTestId('todo-item');
|
|
||||||
await todoItems.nth(1).dblclick();
|
|
||||||
await todoItems.nth(1).getByRole('textbox', { name: 'Edit' }).fill('');
|
|
||||||
await todoItems.nth(1).getByRole('textbox', { name: 'Edit' }).press('Enter');
|
|
||||||
|
|
||||||
await expect(todoItems).toHaveText([
|
|
||||||
TODO_ITEMS[0],
|
|
||||||
TODO_ITEMS[2],
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should cancel edits on escape', async ({ page }) => {
|
|
||||||
const todoItems = page.getByTestId('todo-item');
|
|
||||||
await todoItems.nth(1).dblclick();
|
|
||||||
await todoItems.nth(1).getByRole('textbox', { name: 'Edit' }).fill('buy some sausages');
|
|
||||||
await todoItems.nth(1).getByRole('textbox', { name: 'Edit' }).press('Escape');
|
|
||||||
await expect(todoItems).toHaveText(TODO_ITEMS);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test.describe('Counter', () => {
|
|
||||||
test('should display the current number of todo items', async ({ page }) => {
|
|
||||||
// create a new todo locator
|
|
||||||
const newTodo = page.getByPlaceholder('What needs to be done?');
|
|
||||||
|
|
||||||
// create a todo count locator
|
|
||||||
const todoCount = page.getByTestId('todo-count')
|
|
||||||
|
|
||||||
await newTodo.fill(TODO_ITEMS[0]);
|
|
||||||
await newTodo.press('Enter');
|
|
||||||
|
|
||||||
await expect(todoCount).toContainText('1');
|
|
||||||
|
|
||||||
await newTodo.fill(TODO_ITEMS[1]);
|
|
||||||
await newTodo.press('Enter');
|
|
||||||
await expect(todoCount).toContainText('2');
|
|
||||||
|
|
||||||
await checkNumberOfTodosInLocalStorage(page, 2);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test.describe('Clear completed button', () => {
|
|
||||||
test.beforeEach(async ({ page }) => {
|
|
||||||
await createDefaultTodos(page);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should display the correct text', async ({ page }) => {
|
|
||||||
await page.locator('.todo-list li .toggle').first().check();
|
|
||||||
await expect(page.getByRole('button', { name: 'Clear completed' })).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should remove completed items when clicked', async ({ page }) => {
|
|
||||||
const todoItems = page.getByTestId('todo-item');
|
|
||||||
await todoItems.nth(1).getByRole('checkbox').check();
|
|
||||||
await page.getByRole('button', { name: 'Clear completed' }).click();
|
|
||||||
await expect(todoItems).toHaveCount(2);
|
|
||||||
await expect(todoItems).toHaveText([TODO_ITEMS[0], TODO_ITEMS[2]]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should be hidden when there are no items that are completed', async ({ page }) => {
|
|
||||||
await page.locator('.todo-list li .toggle').first().check();
|
|
||||||
await page.getByRole('button', { name: 'Clear completed' }).click();
|
|
||||||
await expect(page.getByRole('button', { name: 'Clear completed' })).toBeHidden();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test.describe('Persistence', () => {
|
|
||||||
test('should persist its data', async ({ page }) => {
|
|
||||||
// create a new todo locator
|
|
||||||
const newTodo = page.getByPlaceholder('What needs to be done?');
|
|
||||||
|
|
||||||
for (const item of TODO_ITEMS.slice(0, 2)) {
|
|
||||||
await newTodo.fill(item);
|
|
||||||
await newTodo.press('Enter');
|
|
||||||
}
|
|
||||||
|
|
||||||
const todoItems = page.getByTestId('todo-item');
|
|
||||||
const firstTodoCheck = todoItems.nth(0).getByRole('checkbox');
|
|
||||||
await firstTodoCheck.check();
|
|
||||||
await expect(todoItems).toHaveText([TODO_ITEMS[0], TODO_ITEMS[1]]);
|
|
||||||
await expect(firstTodoCheck).toBeChecked();
|
|
||||||
await expect(todoItems).toHaveClass(['completed', '']);
|
|
||||||
|
|
||||||
// Ensure there is 1 completed item.
|
|
||||||
await checkNumberOfCompletedTodosInLocalStorage(page, 1);
|
|
||||||
|
|
||||||
// Now reload.
|
|
||||||
await page.reload();
|
|
||||||
await expect(todoItems).toHaveText([TODO_ITEMS[0], TODO_ITEMS[1]]);
|
|
||||||
await expect(firstTodoCheck).toBeChecked();
|
|
||||||
await expect(todoItems).toHaveClass(['completed', '']);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test.describe('Routing', () => {
|
|
||||||
test.beforeEach(async ({ page }) => {
|
|
||||||
await createDefaultTodos(page);
|
|
||||||
// make sure the app had a chance to save updated todos in storage
|
|
||||||
// before navigating to a new view, otherwise the items can get lost :(
|
|
||||||
// in some frameworks like Durandal
|
|
||||||
await checkTodosInLocalStorage(page, TODO_ITEMS[0]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should allow me to display active items', async ({ page }) => {
|
|
||||||
const todoItem = page.getByTestId('todo-item');
|
|
||||||
await page.getByTestId('todo-item').nth(1).getByRole('checkbox').check();
|
|
||||||
|
|
||||||
await checkNumberOfCompletedTodosInLocalStorage(page, 1);
|
|
||||||
await page.getByRole('link', { name: 'Active' }).click();
|
|
||||||
await expect(todoItem).toHaveCount(2);
|
|
||||||
await expect(todoItem).toHaveText([TODO_ITEMS[0], TODO_ITEMS[2]]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should respect the back button', async ({ page }) => {
|
|
||||||
const todoItem = page.getByTestId('todo-item');
|
|
||||||
await page.getByTestId('todo-item').nth(1).getByRole('checkbox').check();
|
|
||||||
|
|
||||||
await checkNumberOfCompletedTodosInLocalStorage(page, 1);
|
|
||||||
|
|
||||||
await test.step('Showing all items', async () => {
|
|
||||||
await page.getByRole('link', { name: 'All' }).click();
|
|
||||||
await expect(todoItem).toHaveCount(3);
|
|
||||||
});
|
|
||||||
|
|
||||||
await test.step('Showing active items', async () => {
|
|
||||||
await page.getByRole('link', { name: 'Active' }).click();
|
|
||||||
});
|
|
||||||
|
|
||||||
await test.step('Showing completed items', async () => {
|
|
||||||
await page.getByRole('link', { name: 'Completed' }).click();
|
|
||||||
});
|
|
||||||
|
|
||||||
await expect(todoItem).toHaveCount(1);
|
|
||||||
await page.goBack();
|
|
||||||
await expect(todoItem).toHaveCount(2);
|
|
||||||
await page.goBack();
|
|
||||||
await expect(todoItem).toHaveCount(3);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should allow me to display completed items', async ({ page }) => {
|
|
||||||
await page.getByTestId('todo-item').nth(1).getByRole('checkbox').check();
|
|
||||||
await checkNumberOfCompletedTodosInLocalStorage(page, 1);
|
|
||||||
await page.getByRole('link', { name: 'Completed' }).click();
|
|
||||||
await expect(page.getByTestId('todo-item')).toHaveCount(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should allow me to display all items', async ({ page }) => {
|
|
||||||
await page.getByTestId('todo-item').nth(1).getByRole('checkbox').check();
|
|
||||||
await checkNumberOfCompletedTodosInLocalStorage(page, 1);
|
|
||||||
await page.getByRole('link', { name: 'Active' }).click();
|
|
||||||
await page.getByRole('link', { name: 'Completed' }).click();
|
|
||||||
await page.getByRole('link', { name: 'All' }).click();
|
|
||||||
await expect(page.getByTestId('todo-item')).toHaveCount(3);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should highlight the currently applied filter', async ({ page }) => {
|
|
||||||
await expect(page.getByRole('link', { name: 'All' })).toHaveClass('selected');
|
|
||||||
|
|
||||||
//create locators for active and completed links
|
|
||||||
const activeLink = page.getByRole('link', { name: 'Active' });
|
|
||||||
const completedLink = page.getByRole('link', { name: 'Completed' });
|
|
||||||
await activeLink.click();
|
|
||||||
|
|
||||||
// Page change - active items.
|
|
||||||
await expect(activeLink).toHaveClass('selected');
|
|
||||||
await completedLink.click();
|
|
||||||
|
|
||||||
// Page change - completed items.
|
|
||||||
await expect(completedLink).toHaveClass('selected');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
async function createDefaultTodos(page: Page) {
|
|
||||||
// create a new todo locator
|
|
||||||
const newTodo = page.getByPlaceholder('What needs to be done?');
|
|
||||||
|
|
||||||
for (const item of TODO_ITEMS) {
|
|
||||||
await newTodo.fill(item);
|
|
||||||
await newTodo.press('Enter');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function checkNumberOfTodosInLocalStorage(page: Page, expected: number) {
|
|
||||||
return await page.waitForFunction(e => {
|
|
||||||
return JSON.parse(localStorage['react-todos']).length === e;
|
|
||||||
}, expected);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function checkNumberOfCompletedTodosInLocalStorage(page: Page, expected: number) {
|
|
||||||
return await page.waitForFunction(e => {
|
|
||||||
return JSON.parse(localStorage['react-todos']).filter((todo: any) => todo.completed).length === e;
|
|
||||||
}, expected);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function checkTodosInLocalStorage(page: Page, title: string) {
|
|
||||||
return await page.waitForFunction(t => {
|
|
||||||
return JSON.parse(localStorage['react-todos']).map((todo: any) => todo.title).includes(t);
|
|
||||||
}, title);
|
|
||||||
}
|
|
Loading…
Reference in New Issue