Compare commits

...

19 Commits

Author SHA1 Message Date
matthieu42morin 2acd807a49 quickcards, mainpage refactor, content fill 2024-04-28 01:52:16 +02:00
matthieu42morin dc948269b9 Revert "custom shiki highlighter from dominikg"
This reverts commit 9b976986d7.
2024-04-28 01:42:44 +02:00
matthieu42morin 9b976986d7 custom shiki highlighter from dominikg 2024-04-28 01:39:24 +02:00
matthieu42morin c7c2ea60a2 blog server, page functions 2024-04-28 01:30:30 +02:00
matthieu42morin a89f69c69c blogs pages, projs - unfinished 2024-04-28 01:29:37 +02:00
matthieu42morin 35c507e55b delete old/unused components 2024-04-28 01:28:36 +02:00
matthieu42morin 82df5cdaa2 main layout 2slash 2024-04-28 01:24:52 +02:00
matthieu42morin 31fdc9a654 tags refactor to component 2024-04-28 01:24:07 +02:00
matthieu42morin 8c425f89c8 Individual blogPosts - Server + page + Layout 2024-04-28 01:23:33 +02:00
matthieu42morin 74987b8408 New API for posts ! best way to do this. 2024-04-28 01:21:34 +02:00
matthieu42morin dda41c8414 SEO revamped from RodneyLabs - TS support 2024-04-28 01:20:13 +02:00
matthieu42morin 6ce876ab03 Hero 2024-04-28 01:19:43 +02:00
matthieu42morin 0170618b67 Main - Footer, Header - responsive drawer 2024-04-28 01:19:28 +02:00
matthieu42morin 34f70f0412 blog utils & helpers 2024-04-28 01:18:49 +02:00
matthieu42morin f691a6b377 Layouts for feed and /blog 2024-04-28 01:18:02 +02:00
matthieu42morin fd3084fef7 cloudinary serverside images 2024-04-28 01:15:59 +02:00
matthieu42morin e49c5001c2 skills 2024-04-28 01:15:26 +02:00
matthieu42morin de6c8eb073 Obfuscated mail and socials 2024-04-28 01:14:47 +02:00
matthieu42morin 13c52582c4 2slash highlighter 2024-04-28 01:13:28 +02:00
55 changed files with 1465 additions and 1110 deletions

View File

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

View File

@ -1,14 +1,25 @@
import { defineMDSveXConfig as defineConfig } from 'mdsvex';
import remarkExternalLinks from 'remark-external-links';
import remarkSetImagePath from './src/lib/utils/remark-set-image-path.js';
import remarkLinkWithImageAsOnlyChild from './src/lib/utils/remark-link-with-image-as-only-child.js';
import rehypeImgSize from 'rehype-img-size';
import rehypeExternalLinks from 'rehype-external-links';
import rehypeAutolinkHeadings from 'rehype-autolink-headings';
import readingTime from 'mdsvex-reading-time';
import remarkUnwrapImages from 'remark-unwrap-images';
import remarkToc from 'remark-toc';
import rehypeSlug from 'rehype-slug';
// import { highlightCode } from './src/lib/utils/highlighter.js';
import { visit } from 'unist-util-visit';
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} */
const config = defineConfig({
@ -16,6 +27,33 @@ const config = defineConfig({
smartypants: {
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 */
// layout: {
// blog: './src/lib/components/blog/_blog-layout.svelte',
@ -25,7 +63,14 @@ const config = defineConfig({
/* Plugins */
rehypePlugins: [
[rehypeSlug],
[rehypeImgSize]
[rehypeAutolinkHeadings, { behavior: 'wrap' }],
[
rehypeExternalLinks,
{
rel: ['nofollow', 'noopener', 'noreferrer', 'external'],
target: '_blank'
}
]
// [
// /** Custom rehype plugin to add loading="lazy" to all images */
// () => {
@ -42,14 +87,24 @@ const config = defineConfig({
remarkPlugins: [
[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],
remarkSetImagePath,
remarkLinkWithImageAsOnlyChild
[readingTime, { wpm: 200 }],
[remarkFootnotes, { inlineNotes: true }]
// [
// headings,
// {

View File

@ -21,9 +21,14 @@
"test": "vitest",
"lint": "prettier --plugin-search-dir . --check . && eslint .",
"format": "prettier --plugin-search-dir . --write .",
"generate:images": "node ./generate-responsive-image-data.js",
"test-ct": "playwright test -c playwright-ct.config.ts"
},
"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",
"@skeletonlabs/skeleton": "2.0.0",
"@skeletonlabs/tw-plugin": "0.1.0",
@ -32,33 +37,47 @@
"@tailwindcss/typography": "0.5.9",
"@types/js-cookie": "^3.0.6",
"@types/node": "20.5.7",
"@types/object-hash": "^3.0.6",
"@types/prismjs": "^1.26.3",
"@typescript-eslint/eslint-plugin": "^5.62.0",
"@typescript-eslint/parser": "^5.62.0",
"autoprefixer": "10.4.15",
"cloudinary": "^2.2.0",
"emoji-regex": "^10.3.0",
"eslint": "^8.56.0",
"eslint-config-prettier": "^8.10.0",
"eslint-plugin-svelte": "^2.35.1",
"fenceparser": "^2.2.0",
"github-slugger": "^2.0.0",
"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",
"prettier": "^2.8.8",
"prettier-plugin-svelte": "^2.10.1",
"reading-time": "^1.5.0",
"rehype-autolink-headings": "^7.1.0",
"rehype-external-links": "^3.0.0",
"rehype-img-size": "^1.0.1",
"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-unwrap-images": "^4.0.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-check": "^3.6.4",
"tailwind-merge": "^2.2.2",
"tailwindcss": "3.3.3",
"tslib": "^2.6.2",
"typescript": "^5.3.3",
"unist-util-visit": "^5.0.0",
"vite": "^5.1.3",
"vite-imagetools": "^7.0.1",
"vite-plugin-tailwind-purgecss": "0.2.0",
"vitest": "^0.34.6"
},
@ -71,13 +90,15 @@
"@threlte/core": "^6.1.1",
"@threlte/extras": "^8.7.5",
"@yushijinhun/three-minifier-rollup": "^0.4.0",
"front-matter": "^4.0.2",
"highlight.js": "11.8.0",
"latest": "^0.2.0",
"linkedom": "^0.15.6",
"mdsvex": "^0.11.0",
"prismjs": "^1.29.0",
"rss": "^1.2.2",
"svelte-preprocess": "^5.1.3"
"svelte-preprocess": "^5.1.3",
"vanilla-lazyload": "^19.1.3"
},
"type": "module"
}

View File

@ -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}"`);
}
}

View File

@ -1,52 +1,52 @@
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 {
title: string;
level: SkillLevel;
level: Level;
}
export interface SkillSubCategory {
export interface SubCategory {
title: string;
level: number;
skills: Skill[];
}
export interface SkillCategory {
export interface Category {
icon: string;
title: string;
level: number;
subCategories: SkillSubCategory[];
subCategories: SubCategory[];
}
// prettier-ignore
const skillCategories: SkillCategory[] = [
{title:'Software Development', level: 70, subcategories: [
{title:'Programming Languages', level: 75, skills: [
const list: Category[] = [
{ icon: '🚀', title:'Software Development', level: 70, subCategories: [
{ title:'Programming Languages', level: 75, skills: [
{ title: 'JavaScript/TypeScript', level: 'A' },
{ title: 'Python', level: 'B' },
{ title: 'Rust', level: 'C' },
{ title: 'Bash', 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: 'React', level: 'C'},
]},
{title:'Configuration and Performance', level: 70, skills: [
{ title:'Configuration and Performance', level: 70, skills: [
{ title: 'SEO', level: 'B'},
{ title: 'Performance', level: 'B'},
{ title: 'Obfuscation', level: 'B'},
]},
{title:'Databases', level: 70, skills: [
{ title:'Databases', level: 70, skills: [
{ title: 'PostgreSQL', level: 'A' },
{ title: 'MariaDB', level: 'B' },
{ title: 'MongoDB', level: 'C' }
]},
{title:'Testing & Validation', level: 50, skills: [
{ title:'Testing & Validation', level: 50, skills: [
{ title: 'ajv', level: 'A' },
{ title: 'Playwright', level: 'B'},
{ title: 'SEO, performance optimizations', level: 'B'},
]},
]},
{title:'DevOps', level: 70, subcategories: [
{title:'Infrastructure & Configuration Management', level: 80, skills: [
{ icon: '🔁🔁', title:'DevOps', level: 70, subCategories: [
{ title:'Infrastructure & Configuration Management', level: 80, skills: [
{ title: 'Terraform & tooling', level: 'A'},
{ title: 'Ansible', level: 'A'},
{ title: 'Docker, Docker-Compose', level: 'A' },
@ -54,18 +54,16 @@ const skillCategories: SkillCategory[] = [
]},
{ title: 'Version Control & CI/CD', level: 90, skills: [
{ title: 'Git', level: 'A' },
{ title: 'GitHub Ecosystem', level: 'A' },
{ title: 'Gitea', level: 'A' },
{ title: 'GitHub & Gitea Ecosystem', level: 'A' },
{ title: 'Gitlab Ecosystem', level: 'B' }
]},
{ title: 'Monitoring & Observability ', level: 90, skills: [
{ title: 'Grafana', level: 'B' },
{ title: 'Prometheus', level: 'B' },
]},
{title:'Vercel', level: 100, skills: []},
]},
{title:'Cloud Computing', level: 70, subcategories: [
{title:'AWS', level: 80, skills: [
{ icon: '⛅', title:'Cloud Computing', level: 70, subCategories: [
{ title:'AWS', level: 80, skills: [
{ title: 'EC2', level: 'A' },
{ title: 'RDS', level: 'A'},
{ title: 'S3', level: 'A'},
@ -79,11 +77,11 @@ const skillCategories: SkillCategory[] = [
{ title: 'Vault', level: 'B' },
{ title: 'Consul', level: 'C' },
]},
{title:'Vercel', level: 100, skills: []},
{title:'DigitalOcean', level: 100, skills: []},
{ title:'Vercel', level: 100, skills: []},
{ title:'DigitalOcean', level: 100, skills: []},
]},
{title:'System Administration', level: 75, subcategories: [
{title:'Operating Systems', level: 80, skills: [
{ icon: '🔧💻🔒⚙️', title:'System Administration', level: 75, subCategories: [
{ title:'Operating Systems', level: 80, skills: [
{ title: 'Debian / Ubuntu', level: 'A' },
{ title: 'Nix(OS)', level: 'B' },
{ title: 'Arch Linux', level: 'B' },
@ -105,8 +103,8 @@ const skillCategories: SkillCategory[] = [
{ title: 'AWS Secrets Manager', level: 'B' },
]}
]},
{title:"Some fun geek skillz", level: 70, subcategories: [
{title:'mini hardware', level: 80, skills: [
{ icon: '', title:"Some fun geek skillz", level: 70, subCategories: [
{ title:'mini hardware', level: 80, skills: [
{ title: 'Raspberry Pi', level: 'A' },
{ title: 'ESP8266, ESP32', level: 'A' },
{ title: 'Arduino', level: 'A' },
@ -121,19 +119,28 @@ const skillCategories: SkillCategory[] = [
{ title: 'Ultimaker Cura', level: 'B' },
]},
]},
{title:'Languages', level: 70, skills: [
{ icon: '🤐💬', title:'Languages', level: 70, subCategories: [
{ title: 'English', level: 'A' },
{ title: 'Czech', level: 'A' },
{ title: 'French', level: 'B' },
{ title: 'German', level: 'C' }
]},
{title:'Design', level: 70, skills: [
{ title: 'Figma', level: 'A' },
{ icon: '🎨✏️📐', title:'Design', level: 70, subCategories: [
{ title: 'UI/UX', level: 'B' },
{ title: 'LaTeX', level: 'C' },
{ title: 'Wireframing & Prototyping', level: 'B' },
{ title: 'Myriads of image manipulation and generation tools', level: 'B' },
{ title: 'Wireframing, Prototyping, Diagramming', level: 'B', skills: [
{ 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;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,14 +1,24 @@
<script lang="ts">
import MatrixLogo from './logos/MatrixLogo.svelte';
import GiteaLogo from './logos/GiteaLogo.svelte';
import { socialLinks } from '$lib/config';
import MatrixLogo from '$lib/components/logos/MatrixLogo.svelte';
import GiteaLogo from '$lib/components/logos/GiteaLogo.svelte';
import socials from '$lib/socialsObjects';
import ObfuscatedEmail from '$lib/components/ObfuscatedEmail.svelte';
</script>
<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
class="logo-item"
href={link.href}
href={link.title === 'Email' ? `mailto:${link.href}` : link.href}
target="_blank"
rel={link.title === 'Mastodon' ? 'me' : 'noreferrer'}
aria-label={link.title}
@ -21,5 +31,6 @@
<i class={link.icon + ' text-3xl md:text-5xl'} />
{/if}
</a>
{/if}
{/each}
</div>

View File

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

View File

@ -1,12 +1,11 @@
<script lang="ts">
import type { Tag } from '$lib/types/post';
export let selected: Tag | null = null;
let className = '';
export { className as class };
export let className = '';
import { page } from '$app/stores';
import { goto } from '$app/navigation';
let options: Tag[] = ['DevOps', 'Philosophy', 'Updates'];
const tags: Tag[] = ['Blog', 'Projects', 'Updates'];
const clickHandler = (value: Tag) => {
if (value === selected) {
@ -24,7 +23,7 @@
<section class="flex justify-center flex-col items-center {className}">
<h3 class="h3 mb-2 md:mb-3">Sort by category</h3>
<ul class="flex flex-wrap justify-center gap-2">
{#each options as option}
{#each tags as option}
<li>
<button
class="chip {option === selected

View File

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

View File

@ -0,0 +1,3 @@
<article class="text-token prose prose-slate mx-auto dark:prose-invert lg:prose-lg">
<slot />
</article>

View File

@ -1,63 +1,69 @@
<script lang="ts">
import { formatDate } from '$content/utils';
import SEO from '$lib/components/SEO/index.svelte';
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 excerpt: string = '';
export let date: string = '';
export let slug: string = '';
export let title: string;
export let image: string;
export let tags: string[] = [];
export let type: 'blog' | 'projects';
export let post: Post;
const {
postTitle,
datePublished,
featuredImage,
featuredImageAlt,
lastUpdated,
seoMetaDescription: metadescription,
slug,
timeToRead,
ogImage,
ogSquareImage,
twitterImage
} = post || {};
const breadcrumbs = [
{ name: 'Home', slug: '' },
{ name: 'type', slug: post.type },
{ name: postTitle, slug }
];
</script>
<svelte:head>
<title>{title}</title>
<meta name="description" content={excerpt} />
<meta property="og:title" content={title} />
<meta property="og:type" content="article" />
<meta property="og:description" content={excerpt} />
<meta property="og:url" content="https://mattmor.in/{slug}" />
<meta property="og:image" content={image} />
<meta property="og:type" content="article:published_time" />
<meta property="article:published_time" content={date} />
{#each tags as tag (tag)}
<meta property="article:tag" content={tag} />
{/each}
</svelte:head>
<SEO
article
{breadcrumbs}
{slug}
{postTitle}
{datePublished}
{lastUpdated}
{metadescription}
{timeToRead}
{featuredImage}
{ogImage}
{ogSquareImage}
{twitterImage}
/>
<article class="flex justify-center mt-4 mb-8">
<div class=" w-full md:w-[50rem] leading-[177.7%]">
<section class="flex justify-center mt-4 mb-8">
<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>
<img
src="/images/{imagesDirectoryName}/{slug}/{image}"
alt={`${title}`}
class=" bg-black/50 w-full aspect-[21/9] max-h-[540px] rounded-t-lg"
src={featuredImage}
alt={featuredImageAlt}
class=" w-full aspect-[21/9] max-h-[540px] rounded-t-lg"
/>
</header>
<div class="flex-auto flex justify-between items-center py-4 px-2 bg-surface-900">
{#if tags && tags.length > 0}
<div class="flex mb-2 items-center gap-2">
tags: {#each tags as tag}
<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">
<Tags {post} />
<div class="space-y-4 m-8">
<h2 class="h2" data-toc-ignore>{post.title}</h2>
<div class="max-w-none md:w-[720px]">
<slot />
</div>
</div>
<hr class="opacity-50" />
<footer class="p-4 flex justify-start items-center space-x-4" />
</div>
</article>
</section>
<style lang="postcss">
</style>

View File

@ -1,10 +1,10 @@
<script lang="ts">
import { isAnExternalLink } from '$lib/utils/helpers';
import { isAnExternalLink, generateURL } from '$lib/utils/helpers';
import type { Post } from '$lib/types/post';
import { onMount, onDestroy } from 'svelte';
import { formatDate } from '$lib/utils/blog';
export let isMostRecent: boolean = false;
export let type: Post['type'] = 'blog' | 'projects';
export let type: Post['type'];
export let post: Post;
// export let published: boolean;
// export let headlineOrder: 'h3' | '' = '';
@ -13,7 +13,7 @@
//window width
let iteration = 0;
let interval;
let interval: string | number | NodeJS.Timeout | undefined;
onMount(() => {
const interval = setInterval(() => {
@ -26,20 +26,10 @@
}, 1000);
});
const generateURL = (href?: string, slug?: string) => {
if (href) return href;
return `/${type}/${slug}`;
};
$: href = generateURL(post['href'], post.slug);
$: href = generateURL(post['href'], post.type, post.slug);
$: 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(() => {
clearInterval(interval);
});
@ -53,10 +43,15 @@
<!-- Blog in long cols, projects in wide rows -->
<div class="flex {type === 'blog' ? 'flex-col' : 'flex-row'} justify-between w-full h-full">
<header>
<img
<!-- <img
src={`/images/${type}/${post.slug}/${post.image}`}
class="bg-black/200 w-full aspect-[3/2]"
alt="Post preview"
/> -->
<img
src={post.featuredImage}
alt={post.title}
class="bg-black/200 h-[448px] w-[672px] aspect-[3/2]"
/>
</header>
<section class="p-4 space-y-4">
@ -88,9 +83,9 @@
</div>
<div class="mt-auto">
<small>
{#if post.date}
{#if post.datePublished}
<span class="text-sm ml-4">
{displayDate}
{formatDate(post.datePublished)}
</span>
{/if}
</small>

View File

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

View File

@ -1,24 +1,27 @@
<script lang="ts">
import QuickLinks from '$lib/components/home/QuickLinks.svelte';
</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-->
<div class="order-1 max-w-3/4 space-y-8">
<h1 class="h1">Hello, I&#39;m Matt.</h1>
<p class="text-xl opacity-75" data-svelte-h="svelte-169iyno">
A dev with an array of skills from from frontend and devops to design. I have a
strong passion for innovation and change in tech, automation and solving 👾 problems
.
<br />From wearing a lot of 🤠 hats in past projects and startups I became a
generalist, now I am actively deepening my knowledge in software:
<span
<p class="text-xl opacity-75">
A 🧠 that consumes ⚡️ and produces 👾 bug$... No sorry, produces code that works 100%
of the time. Really.
<br /> Usually, this creature you are reading about exhibits "passion" for innovation
and change. It gets dopamine and reward stimuli from solving problems ~~~ the "Ahaa"
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"
>DevOps, CyberSec and AI.</span
>DevOps, Web, AI & IoT</span
>
</p>
<QuickLinks />
</div>
<!-- Logo and buttons-->
<div class="order-2 hidden lg:block">
@ -73,7 +76,7 @@
Download my CV
</a>
</div>
</section>
</section>
<style lang="postcss">
figure {

View File

@ -3,24 +3,24 @@
<i class="fa-solid fa-screwdriver-wrench text-4xl text-primary-500" />
<h3 class="h3">Development</h3>
<p class="opacity-75">
I possess a wide range of skills that enable me to develop visually appealing and
interactive user interfaces for web applications.
I hone my problem solving and coding skills all the time. I try to learn the underlying
mechanics and use abstractions where relevant.
</p>
</div>
<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" />
<h3 class="h3">Design</h3>
<h3 class="h3">Creativity & Presentation</h3>
<p class="opacity-75">
Over the years, I have honed my ability to create visually appealing interfaces that
are both user-friendly and intuitive.
I try to create better, novel ways to approach problems and give my best to present them
in a clear and concise manner.
</p>
</div>
<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" />
<h3 class="h3">User Experience</h3>
<h3 class="h3">Teamwork & Stakeholders</h3>
<p class="opacity-75">
I understand the importance of creating a seamless UX for end-users. Which includes
a solid understanding user behavior.
From my experience with a wide range of roles I understand the how to communication with
stakeholders across an organization.
</p>
</div>
</section>

View File

@ -1,28 +1,27 @@
<script lang="ts">
import * as conf from '$lib/config';
import GiteaLogo from '$lib/components/logos/GiteaLogo.svelte';
import socials from '$lib/socialsObjects';
import ObfuscatedEmail from '../ObfuscatedEmail.svelte';
</script>
<div class="flex space-x-4">
<a
class="btn btn-icon variant-soft-primary hover:variant-filled-primary"
href={conf.socialLinks[2].href}
href={socials[3].href}
target="_blank"
rel="noreferrer"
title="Gitea - private github"
><GiteaLogo clazz="w-6" />
</a><a
class="btn btn-icon variant-soft-primary hover:variant-filled-primary"
href={conf.socialLinks[0].href}
href={socials[1].href}
target="_blank"
rel="noreferrer"
title="LinkedIn"
><i class="fa-brands fa-linkedin" />
</a><a
class="btn btn-icon variant-soft-primary hover:variant-filled-primary"
href="mailto:{conf.email}"
target=""
rel="noreferrer"
title="Email"
><i class="fa-solid fa-envelope" />
</a>
title={socials[1].title}
><i class={socials[1].icon} />
</a><ObfuscatedEmail
mail={socials[0].href}
clazz="btn btn-icon variant-soft-primary hover:variant-filled-primary h-[43px]"
iconClazz={socials[0].icon}
/>
</div>

View File

@ -3,8 +3,6 @@
const drawerStore = getDrawerStore();
import { NavRoutes } from '$lib/config';
import { page } from '$app/stores';
import { browser } from '$app/environment';
// what is my url?
$: classesDrawer = $drawerStore.id === 'mobile-nav' ? 'md:hidden' : '';
</script>

View File

@ -14,11 +14,19 @@
<!-- <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="/">
<p class="sm:pl-4 text-base sm:py-2 sm:mt-0 mt-4 text-center">
All content, unless otherwise stated,
<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>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>.
All content on this website, unless otherwise stated,
<br />by Matthieu Morin, is under copyright © {year},
<br />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 />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>
</a>
</div>

View File

@ -1,10 +1,9 @@
<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';
const drawerStore = getDrawerStore();
// Components
import { getImageLink } from '$lib/images';
import { page } from '$app/stores';
import { NavRoutes } from '$lib/config';
@ -23,9 +22,6 @@
};
drawerStore.open(drawerSettings);
}
// Local
const imgPlaceholder = getImageLink({ id: 'linky', w: 128, h: 128 });
</script>
<section

View File

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

View File

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

View File

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

View File

@ -1,30 +1,51 @@
import { dev } from '$app/environment';
export const title = "Matt's Portfolio";
export const description =
/* Website config */
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.';
export const url = dev ? 'http://localhost:5174' : 'https://mattmor.in';
export const author = 'Matt Morin';
export const backgroundColor = '#111827';
export const themeColor = '#3b82f6';
export const logo = '/Logo.png';
export const keywords = 'Dev, FOSS, Nix, Philosopher, DevOps, Climate';
export const ogLanguage = 'en_US';
export const siteLanguage = 'en-US';
const siteUrl = dev ? 'http://localhost:5174' : 'https://mattmor.in';
const author = 'Matt Morin';
const backgroundColor = '#111827';
const themeColor = '#3b82f6';
const logo = '/Logo.png';
const keywords = 'Dev, FOSS, Nix, Philosopher, DevOps, Climate';
const ogLanguage = 'en_US';
const siteLanguage = 'en-US';
const defaultOgImage = '';
const defaultOgSquareImage = '';
const defaultTwitterImage = '';
// prettier-ignore
export const socialLinks = [
{ title: 'LinkedIn', href: 'https://linkedin.com/in/mattmor-in', icon: 'fa-brands fa-linkedin'},
{ title: 'Matrix', href: '', icon: './MatrixLogo' },
{ title: 'Gitea', href: 'https://git.mattmor.in', icon: './GiteaLogo' },
{ title: 'Mastodon', href: 'https://mastodon.social/@matt_mor', icon: 'fa-brands fa-mastodon'},
{ title: 'RSS feed', href: '/blog/feed', icon: 'fa-regular fa-square-rss' },
{ title: 'email', href: 'matt.b.morin@protonmail.com', icon: 'fa-regular mail'}
];
const website = { siteTitle, siteShortTitle, description, siteUrl, author, backgroundColor, themeColor, logo, keywords, ogLanguage, siteLanguage, defaultOgImage, defaultOgSquareImage, defaultTwitterImage };
/* Social Nicknames */
const GHNick = 'matthieu42morin';
const LINick = 'mattmor-in';
const MatrixServer = '';
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
export const NavRoutes = [
const NavRoutes = [
{ title: 'Home', href: '/' },
{ title: 'Blog', href: '/blog' },
{ title: 'Projects', href: '/projects' }
];
export { website, socialNicks, socialLinks, NavRoutes };

9
src/lib/constants.ts Normal file
View File

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

12
src/lib/socialsObjects.ts Normal file
View File

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

View File

@ -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 {
type?: 'Blog' | 'projects' | string;
date?: string;
slug: string;
title: string;
postTitle: string;
type: 'blog' | 'projects';
excerpt: string;
image: string;
slug?: string;
datePublished: string;
lastUpdated: string;
seoMetaDescription: string;
focusKeyphrase: string;
featuredImage: string;
featuredImageAlt: string;
imagePublicId: string;
href?: string;
tags?: Tag[];
subtitle?: string;
teaserImage: string;
title: string;
isNotAnActualPost?: boolean;
timeToRead: number;
isAnExternalLink?: boolean;
ogImage?: string;
ogSquareImage?: string;
twitterImage?: string;
}

96
src/lib/utils/blog.ts Normal file
View File

@ -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.';
}
};

View File

@ -1,4 +1,55 @@
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.
@ -105,3 +156,8 @@ export const scrollIntoView = (selector: string) => {
behavior: mediaQuery.matches ? 'auto' : 'smooth'
});
};
export const generateURL = (href?: string, type: string, slug?: string) => {
if (href) return href;
return `/${type}/${slug}`;
};

33
src/lib/utils/images.ts Normal file
View File

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

View File

@ -1,7 +1,6 @@
import type { MdsvexImport } from '$content/types';
import type { MarkdownMetadata } from '$content/types';
import type { MdsvexImport, MarkdownMetadata } from '$lib/types/post';
import type { Project } from '$lib/types/projects';
import { parseReadContent } from './utils';
import { parseReadContent } from '$lib/utils/blog';
import { error } from '@sveltejs/kit';
/**

View File

@ -97,19 +97,27 @@
});
});
//mode based on system preference
import { autoModeWatcher } from '@skeletonlabs/skeleton';
// Highlight JS
import hljs from 'highlight.js';
import 'highlight.js/styles/nord.css';
import { storeHighlightJs } from '@skeletonlabs/skeleton';
// import hljs from 'highlight.js';
// import 'highlight.js/styles/nord.css';
// import { storeHighlightJs } from '@skeletonlabs/skeleton';
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>
<svelte:head>
<link
rel="canonical"
href={removeTrailingSlash(`https://www.mattmor.in&#x24;{&#x24;page.url.pathname}`)}
/>
<link rel="canonical" />
<!-- {@html '<script>(' + autoModeWatcher.toString() + ')();</script>'} -->
</svelte:head>
<!-- <Analytics />

View File

@ -1,48 +1,27 @@
<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 QuickCards from '$lib/components/home/QuickCards.svelte';
import * as conf from '$lib/config';
import { website } from '$lib/config';
</script>
<svelte:head>
<title>Matt Morin</title>
<meta property="og:type" content="website" />
<meta name="description" content={conf.description} />
<meta property="og:title" content={conf.title} />
<meta name="description" content={website.description} />
<meta property="og:title" content={website.siteTitle} />
<meta property="og:type" content="article" />
<meta property="og:description" content={conf.description} />
<meta property="og:url" content={conf.url} />
<meta property="og:description" content={website.description} />
<meta property="og:url" content={website.url} />
<meta property="og:image" content="/images/profile-pic.png" />
</svelte:head>
<div class="mt-24 container h-full mx-auto flex-col justify-center items-center md:w-3/4">
<!-- <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> -->
<div class="mt-24 container h-full mx-auto flex-col justify-center items-center md:w-3/4 space-y-8">
<HeroSection />
<SkillContainer />
<QuickCards />
<SkillContainer />
</div>

View File

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

View File

@ -1,7 +1,7 @@
import { listPosts } from '$content/blog';
import type { PageLoad } from './$types';
export const load = async () => {
return {
posts: listPosts()
};
export const load = async ({ fetch }) => {
const response = await fetch('api/posts');
const posts: Post[] = await response.json();
return { posts };
};

View File

@ -1,60 +1,73 @@
<script lang="ts">
import PostPreview from '$lib/components/blog/PostPreview.svelte';
import CategoryFilter from '$lib/components/blog/CategoryFilter.svelte';
import ogSquareImageSrc from '$lib/assets/home/home-open-graph-square.jpg';
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 { Tag } from '$lib/types/post';
import { page } from '$app/stores';
import { onMount } from 'svelte';
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));
onMount(() => {
const tagParam = $page.url.searchParams.get('tag');
if (!filter && typeof tagParam == 'string') {
filter = tagParam as Tag;
let title = 'Home';
let tags = ['blog', 'home'];
const breadcrumbs = [
{
name: 'Home',
slug: ''
}
});
];
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>
<div>
<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>
<SEO {...seoProps} />
{#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 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>
<Feed {data} />

View File

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

View File

@ -1,16 +1,10 @@
<script lang="ts">
import BlogContentLayout from '$lib/components/blog/BlogContentLayout.svelte';
import type { PageData } from './$types';
import PostLayout from '$lib/components/blog/PostLayout.svelte';
import type { PageData } from '../$types';
export let data: PageData;
</script>
<!-- <svelte:head>
<title>{data.post.title}</title>
<meta property="og:type" content="article" />
<meta property="og:title" content={data.post.title} />
</svelte:head> -->
<BlogContentLayout post={data.post}>
<svelte:component this={data.Component} />
</BlogContentLayout>
<PostLayout post={data.meta}>
<svelte:component this={data.content} />
</PostLayout>

View File

@ -1,14 +1,19 @@
import { getPost, listPosts } from '$content/blog';
import type { PageLoad } from './$types';
import type { PageServerLoad } from './$types';
export const entries = async () => {
const posts = await listPosts();
return posts
.filter((post) => post.slug !== undefined)
.map((post) => ({ slug: post.slug as string }));
};
import { error } from '@sveltejs/kit';
export const load: PageLoad = async ({ params, parent }) => {
await parent();
return await getPost(params.slug);
export const load: PageServerLoad = async ({ params }) => {
try {
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;
}
};

View File

@ -1,4 +1,4 @@
import { listPosts } from '$content/blog';
import { listPosts } from '$lib/utils/blog';
import RSS from 'rss';
import type { Post } from '$lib/types/post';

View File

@ -1 +0,0 @@
<p>There is nothing to see yet</p>

View File

@ -1,7 +1,7 @@
import { listProjects } from '$content/projects';
import type { PageLoad } from './$types';
export const load = () => {
return {
projects: listProjects()
};
export const load = async ({ fetch }) => {
const response = await fetch('api/posts');
const projects: Projects[] = await response.json();
return { projects };
};

View File

@ -1,6 +1,6 @@
<script lang="ts">
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;
</script>

View File

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

View File

@ -1,5 +1,5 @@
<script lang="ts">
import ProjectsContentLayout from '$lib/components/projects/ProjectsContentLayout.svelte';
import ProjectsContentLayout from '$lib/components/blog/PostLayout.svelte';
import type { PageData } from './$types';
export let data: PageData;

View File

@ -1,4 +1,4 @@
import { getProject, listProjects } from '$content/projects';
import { getProject, listProjects } from '$lib/utils/projects';
import type { PageLoad } from './$types';
export const entries = () => listProjects().map((post) => ({ slug: post.slug }));

View File

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