Add new blog post layout and content, update
project and blog content, and add filter category component
This commit is contained in:
parent
9a5d98b033
commit
eb0386884e
|
@ -0,0 +1,33 @@
|
||||||
|
import type { BlogPost } from '$lib/types/blog';
|
||||||
|
import type { MarkdownMetadata } from '$lib/contents/types';
|
||||||
|
import type { MdsvexImport } from './types';
|
||||||
|
import { parseReadContent } from '../../content/utils';
|
||||||
|
import { error } from '@sveltejs/kit';
|
||||||
|
|
||||||
|
|
||||||
|
export function listBlogPosts() {
|
||||||
|
const posts = import.meta.glob<BlogPost>('./blog/*.md', {
|
||||||
|
eager: true,
|
||||||
|
import: 'metadata'
|
||||||
|
});
|
||||||
|
|
||||||
|
return parseReadContent(posts);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getBlogPostMetadata(slug: string) {
|
||||||
|
const { post } = await getBlogPost(slug);
|
||||||
|
return post;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getBlogPost(slug: string) {
|
||||||
|
try {
|
||||||
|
const data: MdsvexImport<BlogPost & MarkdownMetadata> = await import(`./blog/${slug}.md`);
|
||||||
|
|
||||||
|
return {
|
||||||
|
post: { ...data.metadata, slug },
|
||||||
|
Component: data.default
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
throw error(404, `Unable to find blog post "${slug}"`);
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,39 @@
|
||||||
|
import type { MarkdownMetadata, MdsvexImport } from './types';
|
||||||
|
import { parseReadContent } from './utils';
|
||||||
|
import { error } from '@sveltejs/kit';
|
||||||
|
|
||||||
|
export interface Project extends MarkdownMetadata {
|
||||||
|
title: string;
|
||||||
|
excerpt: string;
|
||||||
|
slug: string;
|
||||||
|
image: string;
|
||||||
|
date: string;
|
||||||
|
pageTitle: string;
|
||||||
|
pageDescription: string;
|
||||||
|
keywords: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets all the projects metadata
|
||||||
|
*/
|
||||||
|
export function listProjects() {
|
||||||
|
const projects = import.meta.glob<Project>('./projects/*.md', {
|
||||||
|
eager: true,
|
||||||
|
import: 'metadata'
|
||||||
|
});
|
||||||
|
|
||||||
|
return parseReadContent(projects);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getProject(slug: string) {
|
||||||
|
try {
|
||||||
|
const data: MdsvexImport<Project> = await import(`./projects/${slug}.md`);
|
||||||
|
|
||||||
|
return {
|
||||||
|
post: { ...data.metadata, slug },
|
||||||
|
Component: data.default
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
throw error(404, `Unable to find project "${slug}"`);
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,18 @@
|
||||||
|
<script lang="ts">
|
||||||
|
|
||||||
|
import PostContentLayout from './post-content-layout.svelte';
|
||||||
|
|
||||||
|
import type { BlogPost } from '$lib/types/blog';
|
||||||
|
|
||||||
|
|
||||||
|
export let post: BlogPost;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<PostContentLayout
|
||||||
|
{...post}
|
||||||
|
imagesDirectoryName="blog"
|
||||||
|
baseUrl="https://www.mattmor.in/blog/"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</PostContentLayout>
|
||||||
|
|
|
@ -0,0 +1,50 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import type { BlogTag } from '$lib/types/blog';
|
||||||
|
import Button from '$lib/components/ui-library/button/button.svelte';
|
||||||
|
export let selected: BlogTag;
|
||||||
|
let className = '';
|
||||||
|
export { className as class };
|
||||||
|
import { page } from '$app/stores';
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
|
||||||
|
let options: BlogTag[] = [
|
||||||
|
'Company building',
|
||||||
|
'Engineering',
|
||||||
|
'Gitpod updates',
|
||||||
|
'Developer experience',
|
||||||
|
];
|
||||||
|
|
||||||
|
const clickHandler = (value: BlogTag) => {
|
||||||
|
if (value === selected) {
|
||||||
|
goto(`/blog`, { keepFocus: true, noScroll: true });
|
||||||
|
selected = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let query = new URLSearchParams($page.url.searchParams.toString());
|
||||||
|
query.set('tag', value);
|
||||||
|
goto(`?${query.toString()}`, { keepFocus: true, noScroll: true });
|
||||||
|
selected = value;
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<section class="flex justify-center flex-col items-center {className}">
|
||||||
|
<p class="text-semibold mb-2 md:mb-4">
|
||||||
|
Sort by category
|
||||||
|
</p>
|
||||||
|
<ul class="flex flex-wrap justify-center gap-2">
|
||||||
|
{#each options as option}
|
||||||
|
<li>
|
||||||
|
<Button
|
||||||
|
class={option === selected
|
||||||
|
? 'dark:!bg-primary dark:!text-black !bg-black !text-white'
|
||||||
|
: ''}
|
||||||
|
variant="cta"
|
||||||
|
size="medium"
|
||||||
|
on:click={() => clickHandler(option)}
|
||||||
|
>
|
||||||
|
{option}
|
||||||
|
</Button>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
</section>
|
|
@ -0,0 +1,58 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { formatDate } from '$src/content/utils.js';
|
||||||
|
|
||||||
|
export let baseUrl: string;
|
||||||
|
export let imagesDirectoryName: string;
|
||||||
|
|
||||||
|
export let date: string = '';
|
||||||
|
export let author: string = '';
|
||||||
|
export let slug: string = '';
|
||||||
|
export let title: string;
|
||||||
|
export let image: string;
|
||||||
|
export let teaserImage: string;
|
||||||
|
export let excerpt: string;
|
||||||
|
export let tags: string[] = [];
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<article>
|
||||||
|
<div class="flex justify-center mt-4 mb-8">
|
||||||
|
<div class="w-full lg:w-[50rem] leading-[177.7%]">
|
||||||
|
<img
|
||||||
|
src="/images/{imagesDirectoryName}/{slug}/{teaserImage || image}"
|
||||||
|
alt={`${title}`}
|
||||||
|
class="max-h-[540px] rounded-tl-2xl rounded-tr-[1.3rem]"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
class="content-blog prose prose-img:rounded-tl-2xl prose-img:rounded-tr-[1.3rem] max-w-none text-body"
|
||||||
|
>
|
||||||
|
<p
|
||||||
|
class="{tags && tags.length > 0
|
||||||
|
? '!mb-2'
|
||||||
|
: '!mb-2'} mt-[1.875rem] text-body text-white"
|
||||||
|
>
|
||||||
|
{formatDate(date)}
|
||||||
|
</p>
|
||||||
|
{#if tags && tags.length > 0}
|
||||||
|
<div class="flex mb-2 items-center gap-2">
|
||||||
|
{#each tags as tag}
|
||||||
|
<a
|
||||||
|
data-sveltekit-preload-data="hover"
|
||||||
|
href="/blog?{new URLSearchParams({tag}).toString()}"
|
||||||
|
>
|
||||||
|
<span class="chip variant-ghost-surface text-white">{tag}</span>
|
||||||
|
</a>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<style lang="postcss">
|
||||||
|
.prose :global(nav.toc) {
|
||||||
|
@apply hidden;
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -0,0 +1,82 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { isAnExternalLink } from '$lib/utils/helpers';
|
||||||
|
import type { BlogPost } from '$lib/types/blog';
|
||||||
|
|
||||||
|
export let post: BlogPost;
|
||||||
|
export let isMostRecent: boolean = false;
|
||||||
|
export let type: 'blog';
|
||||||
|
export let layout: 'row' | 'column' = 'column';
|
||||||
|
export let published: boolean = true;
|
||||||
|
export let headlineOrder: 'h3' | '' = '';
|
||||||
|
export let badge: string = '';
|
||||||
|
export let textWidth: string = '';
|
||||||
|
|
||||||
|
const generateURL = (href?: string, slug?: string) => {
|
||||||
|
if (href) return href;
|
||||||
|
return `/${type}/${slug}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
$: href = generateURL(post['href'], post.slug);
|
||||||
|
|
||||||
|
$: target = post && post['href'] && isAnExternalLink(post['href']) ? '_blank' : undefined;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<a
|
||||||
|
{href}
|
||||||
|
{target}
|
||||||
|
data-sveltekit-preload-data="hover"
|
||||||
|
class="card"
|
||||||
|
data-analytics={`{"context":"grid","variant":"preview"}`}
|
||||||
|
>
|
||||||
|
<div class="w-full text-token grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<!-- Detailed -->
|
||||||
|
<a
|
||||||
|
class="card bg-gradient-to-br variant-gradient-tertiary-primary card-hover overflow-hidden"
|
||||||
|
href="/blog/{post.slug}"
|
||||||
|
>
|
||||||
|
<header>
|
||||||
|
<img
|
||||||
|
src="/images/blog/{post.slug}/{post.image}"
|
||||||
|
class="bg-black/50 w-full aspect-[21/9]"
|
||||||
|
alt="Post"
|
||||||
|
/>
|
||||||
|
</header>
|
||||||
|
<div class="p-4 space-y-4">
|
||||||
|
<h6 class="text-2" data-toc-ignore>
|
||||||
|
<div class="items-center flex gap-2">
|
||||||
|
{#if post.tags}
|
||||||
|
{#each post.tags as tag}
|
||||||
|
<span class="chip variant-ghost-surface">{tag}</span>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</h6>
|
||||||
|
<h3 class="h3" data-toc-ignore>{post.title}</h3>
|
||||||
|
<article>
|
||||||
|
<p>
|
||||||
|
<!-- cspell:disable -->
|
||||||
|
{post.excerpt}
|
||||||
|
<!-- cspell:enable -->
|
||||||
|
</p>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
<hr class="opacity-50" />
|
||||||
|
<footer class="p-4 flex justify-start items-center space-x-4">
|
||||||
|
<div class="flex-auto flex justify-between items-center">
|
||||||
|
<h6 class="font-bold" data-toc-ignore>By Matt</h6>
|
||||||
|
<small>
|
||||||
|
{#if post.date}
|
||||||
|
<span class="date text-p-small ml-macro">
|
||||||
|
{new Date(Date.parse(post.date)).toLocaleDateString(undefined, {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric'
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</a>
|
|
@ -0,0 +1,4 @@
|
||||||
|
<script lang="ts">
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
|
@ -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;
|
|
||||||
}
|
|
|
@ -0,0 +1,7 @@
|
||||||
|
import { listBlogPosts } from '$content/blog';
|
||||||
|
|
||||||
|
export const load = async () => {
|
||||||
|
return {
|
||||||
|
posts: listBlogPosts(),
|
||||||
|
};
|
||||||
|
};
|
|
@ -0,0 +1,96 @@
|
||||||
|
<!-- <script lang="ts">
|
||||||
|
import { formatDate } from '$src/content/utils.js';
|
||||||
|
import { BlogPost } from '$lib/components/blog';
|
||||||
|
import * as config from '$lib/config';
|
||||||
|
|
||||||
|
export let data;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>{config.title}</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<ul class="posts">
|
||||||
|
{#each data.posts as post}
|
||||||
|
<li class="post">
|
||||||
|
<a href={post.slug} class="title"></a>
|
||||||
|
<p class="date">{formatDate(post.date, 'full', 'en')}</p>
|
||||||
|
<p class="description"></p>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
<div class="flex w-3/4">
|
||||||
|
<BlogPost />
|
||||||
|
<div>Some shit</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section class="flex max-w-[600px]">
|
||||||
|
{#each data.posts as post}
|
||||||
|
<li class="posts">
|
||||||
|
<a href={post.slug}>
|
||||||
|
<BlogPost>
|
||||||
|
<svelte:fragment slot="header">{post.title}</svelte:fragment>
|
||||||
|
<p class="date" />
|
||||||
|
|
||||||
|
<svelte:fragment slot="footer"
|
||||||
|
>{formatDate(post.date)}, {post.description}</svelte:fragment
|
||||||
|
>
|
||||||
|
</BlogPost>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</section> -->
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import PostPreview from '$lib/components/blog/post-preview.svelte';
|
||||||
|
import type { PageData } from './$types';
|
||||||
|
import type { BlogTag } from '$lib/types/blog';
|
||||||
|
import { page } from '$app/stores';
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
|
||||||
|
export let data: PageData;
|
||||||
|
|
||||||
|
let filter: BlogTag | null = null;
|
||||||
|
|
||||||
|
$: 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 BlogTag;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const displayAmount = 12;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="blog-layout">
|
||||||
|
<div>
|
||||||
|
<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}
|
||||||
|
<div>
|
||||||
|
<h2 class="mb-small 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>
|
|
@ -0,0 +1,27 @@
|
||||||
|
import { listBlogPosts } 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 = listBlogPosts();
|
||||||
|
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),
|
||||||
|
};
|
||||||
|
}
|
|
@ -0,0 +1 @@
|
||||||
|
<slot />
|
|
@ -0,0 +1,14 @@
|
||||||
|
import { getBlogPost, listBlogPosts } from '$content/blog.js';
|
||||||
|
import type { PageLoad } from './$types';
|
||||||
|
|
||||||
|
export const entries = async () => {
|
||||||
|
const posts = await listBlogPosts();
|
||||||
|
return posts
|
||||||
|
.filter((post) => post.slug !== undefined)
|
||||||
|
.map((post) => ({ slug: post.slug as string }));
|
||||||
|
};
|
||||||
|
|
||||||
|
export const load: PageLoad = async ({ params, parent }) => {
|
||||||
|
await parent();
|
||||||
|
return await getBlogPost(params.slug);
|
||||||
|
};
|
|
@ -0,0 +1,66 @@
|
||||||
|
import { removeTrailingSlash } from '$lib/utils/helpers';
|
||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
import { listBlogPosts } from '$content/blog';
|
||||||
|
|
||||||
|
// prettier-ignore
|
||||||
|
const sitemap = (pages: string[]) => `<?xml version="1.0" encoding="UTF-8" ?>
|
||||||
|
<urlset
|
||||||
|
xmlns="https://www.sitemaps.org/schemas/sitemap/0.9"
|
||||||
|
xmlns:news="https://www.google.com/schemas/sitemap-news/0.9"
|
||||||
|
xmlns:xhtml="https://www.w3.org/1999/xhtml"
|
||||||
|
xmlns:mobile="https://www.google.com/schemas/sitemap-mobile/1.0"
|
||||||
|
xmlns:image="https://www.google.com/schemas/sitemap-image/1.1"
|
||||||
|
xmlns:video="https://www.google.com/schemas/sitemap-video/1.1"
|
||||||
|
>
|
||||||
|
${pages.map((page) => `<url><loc>${removeTrailingSlash(page)}</loc></url>`).join('')}
|
||||||
|
</urlset>
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const GET: RequestHandler = async () => {
|
||||||
|
const staticPages = Object.keys(
|
||||||
|
// For other static pages. Except content pages - changelogs, guides, blog posts, guides etc.
|
||||||
|
import.meta.glob('/src/routes/**/!(_)*.{svelte,md}'),
|
||||||
|
)
|
||||||
|
.filter((page) => {
|
||||||
|
const filters = [
|
||||||
|
'/src/routes/index.svelte',
|
||||||
|
'_',
|
||||||
|
'404',
|
||||||
|
'slug]',
|
||||||
|
'title]',
|
||||||
|
'src/routes/docs/introduction/getting-started',
|
||||||
|
'extension-activation',
|
||||||
|
'unsubscribe',
|
||||||
|
'subscribe',
|
||||||
|
'stay-connected',
|
||||||
|
'extension-uninstall',
|
||||||
|
'+error',
|
||||||
|
'+layout',
|
||||||
|
];
|
||||||
|
return !filters.find((filter) => page.includes(filter));
|
||||||
|
})
|
||||||
|
.map((page) => {
|
||||||
|
return page
|
||||||
|
.replace('/src/routes', 'https://www.gitpod.io')
|
||||||
|
.replace('/index.md', '/')
|
||||||
|
.replace('.md', '/')
|
||||||
|
.replace('/index.svelte', '/')
|
||||||
|
.replace('.svelte', '/')
|
||||||
|
.replace('/+page', '');
|
||||||
|
});
|
||||||
|
|
||||||
|
const blogPosts = listBlogPosts().map(
|
||||||
|
(post) => `https://www.gitpod.io/blog/${post.slug}`,
|
||||||
|
);
|
||||||
|
const renderedSitemap = sitemap([
|
||||||
|
...staticPages,
|
||||||
|
...blogPosts,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return new Response(renderedSitemap, {
|
||||||
|
headers: {
|
||||||
|
'Cache-Control': 'max-age=0, s-maxage=3600',
|
||||||
|
'Content-Type': 'application/xml',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
Loading…
Reference in New Issue