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