feat: Blog catalog and tags #7

Merged
aniva merged 11 commits from post/page into main 2024-10-10 14:49:18 -07:00
26 changed files with 325 additions and 59 deletions

View File

@ -33,7 +33,7 @@ npm run dev [-- --open]
### Formatting ### Formatting
``` bash ``` bash
npx run eslint npx eslint
``` ```
### Testing ### Testing

View File

@ -3,6 +3,7 @@ title: Placeholder 1
date: '2024-09-01' date: '2024-09-01'
description: "This is a placeholder description" description: "This is a placeholder description"
tags: ["a123", "ボースト"] tags: ["a123", "ボースト"]
series: ["placeholder"]
--- ---
# 1st Level Heading # 1st Level Heading

View File

@ -0,0 +1,8 @@
---
title: Placeholder 2
date: '2024-09-20'
description: "This is a placeholder description"
tags: ["a123"]
series: ["placeholder"]
---
## Content

View File

@ -0,0 +1,8 @@
---
title: Placeholder 3
date: '2024-09-24'
description: "This is a placeholder description"
tags: []
series: ["placeholder"]
---
## Content

View File

@ -0,0 +1,6 @@
---
title: The Perfect Math Class
date: '2024-09-15'
tags: ["Cirno"]
---
# Content

11
src/hooks.ts Normal file
View File

@ -0,0 +1,11 @@
import type { Reroute } from '@sveltejs/kit';
const translated: Record<string, string> = {
'/post': '/page/1',
};
export const reroute: Reroute = ({ url }) => {
if (url.pathname in translated) {
return translated[url.pathname];
}
};

View File

@ -3,11 +3,15 @@
export let metadata : { title: string, description: string, tags: [string], date: Date }; export let metadata : { title: string, description: string, tags: [string], date: Date };
export let link: Option<string> = null; export let link: Option<string> = null;
const date = metadata.date.toLocaleDateString(); const date = metadata.date.toISOString().slice(0,-14);
const series = metadata?.series || [];
</script> </script>
<div id="post-heading"> <div id="post-heading">
<h2> <h2>
{#each series as seriesTag}
<p class="series-tag">{seriesTag}</p>
{/each}
{#if link} {#if link}
<a id="post-title" href={link}>{metadata.title}</a> <a id="post-title" href={link}>{metadata.title}</a>
{:else} {:else}
@ -25,7 +29,7 @@
<DividerVertical /> <DividerVertical />
</div> </div>
{/if} {/if}
<a href="/">{tag}</a> <a href="/tag/{tag}">{tag}</a>
{/each} {/each}
</p> </p>
<p class="text-l text-gray-500">{date}</p> <p class="text-l text-gray-500">{date}</p>
@ -35,9 +39,12 @@
<style> <style>
#post-heading { #post-heading {
@apply flex flex-col; @apply flex flex-col;
width: 100%; margin: .5em 1em .5em 1em;
} }
#post-title { #post-title {
@apply text-3xl; @apply text-3xl;
} }
.series-tag {
color: theme('colors.sunglow.600');
}
</style> </style>

41
src/lib/posts.ts Normal file
View File

@ -0,0 +1,41 @@
export async function getPosts(tag: string | null = null) {
const allPostFiles = import.meta.glob('$content/post/*.md');
const iterablePostFiles = Object.entries(allPostFiles);
let posts = await Promise.all(
iterablePostFiles.map(async ([pathMarkdown, resolver]) => {
const { metadata } = await resolver();
const pathPost = "/post/" + pathMarkdown.slice(pathMarkdown.lastIndexOf("/") + 1, -".md".length);
return {
meta: {
...metadata,
date: new Date(metadata.date),
},
path: pathPost
};
})
);
if (tag)
posts = posts.filter(obj => obj.meta.tags.includes(tag))
posts.sort((post1, post2) => {
const date1: Date = post1.meta.date;
const date2: Date = post2.meta.date;
return date2.getTime() - date1.getTime();
});
return posts;
}
export async function getTags() {
const allPostFiles = import.meta.glob('$content/post/*.md');
const iterablePostFiles = Object.entries(allPostFiles);
const allPosts: string[][] = await Promise.all(
iterablePostFiles.map(async ([_, resolver]) => {
const { metadata } = await resolver();
return metadata.tags;
})
);
return new Set(allPosts.flat());
}

View File

@ -1,6 +1,7 @@
export const routes: { route: string, name: string, inactive?: boolean }[] = [ export const routes: { route: string, name: string, disabled?: boolean }[] = [
{ route: "/", name: "Home" }, { route: "/", name: "Home" },
{ route: "/post", name: "Blog" }, { route: "/post", name: "Blog" },
{ route: "/art", name: "Art", inactive: true }, { route: "/tag", name: "Tags" },
{ route: "/gallery", name: "Gallery", disabled: true },
{ route: "/archives", name: "Archives" }, { route: "/archives", name: "Archives" },
]; ];

View File

@ -3,6 +3,9 @@
import { page } from '$app/stores'; import { page } from '$app/stores';
import Footer from "./Footer.svelte"; import Footer from "./Footer.svelte";
import Navbar from "./Navbar.svelte"; import Navbar from "./Navbar.svelte";
import PageTransition from "./PageTransition.svelte";
export let data : { url: string };
</script> </script>
<!-- Homepage has its own navbar with animations --> <!-- Homepage has its own navbar with animations -->
@ -12,24 +15,30 @@
<Navbar /> <Navbar />
<slot name="header" /> <slot name="header" />
</div> </div>
<div id="content"> <main>
<slot /> <PageTransition url={data.url}>
<slot />
</PageTransition>
<Footer /> <Footer />
</div> </main>
</div> </div>
{:else} {:else}
<slot /> <!-- On the home page -->
<PageTransition url={data.url}>
<slot />
</PageTransition>
<Footer /> <Footer />
{/if} {/if}
<style> <style>
:global(.navbar-link) { :global(.nav-link) {
@apply text-lg; @apply text-lg;
font-weight: 500; font-weight: 500;
font-family: serif; font-family: serif;
} }
:global(p.navbar-link) { :global(a.disabled-link) {
color: rgb(128,128,128); color: rgb(128,128,128);
pointer-events: none;
} }
:global(a) { :global(a) {
color: theme('colors.eucalyptus.400'); color: theme('colors.eucalyptus.400');
@ -64,7 +73,7 @@
} }
} }
} }
:global(a.active-link) { :global(a.current-link) {
color: theme('colors.magenta.500'); color: theme('colors.magenta.500');
&::after { &::after {
@ -97,7 +106,7 @@
gap: 20px; gap: 20px;
} }
} }
#content { main {
width: min(100vw, max(50vw, 100vh)); width: min(100vw, max(50vw, 100vh));
grid-area: content; grid-area: content;
} }

View File

@ -2,3 +2,9 @@ export const prerender = true;
// Prevent page 404 on refresh // Prevent page 404 on refresh
export const trailingSlash = 'always'; export const trailingSlash = 'always';
export async function load({ url }) {
return {
url: url.pathname
}
}

View File

@ -53,11 +53,7 @@
{#each routes as item} {#each routes as item}
{#if item.route !== "/"} {#if item.route !== "/"}
<Separator class="bg-gray-500 dark:bg-gray-100" orientation="vertical" /> <Separator class="bg-gray-500 dark:bg-gray-100" orientation="vertical" />
{#if item.inactive ?? false} <a class="navbar-link" href={item.route} class:disabled-link={item.disabled}>{item.name}</a>
<p class="navbar-link">{item.name}</p>
{:else}
<a class="navbar-link" href={item.route}>{item.name}</a>
{/if}
{/if} {/if}
{/each} {/each}
</div> </div>

View File

@ -1,26 +1,32 @@
<script lang="ts"> <script lang="ts">
import { page } from '$app/stores'; import { page } from '$app/stores';
import { routes } from "$lib/sitemap.ts" import { routes } from "$lib/sitemap.ts"
import { name } from '$content/metadata.json';
function isActiveLink(pathname, route) { function isCurrentLink(pathname, route) {
return route != "/" && pathname.startsWith(route); return route != "/" && pathname.startsWith(route)
|| route == "/post" && pathname.startsWith("/page");
} }
</script> </script>
<div id="navbar" class="h-5 space-x-4"> <div id="navbar" class="h-5 space-x-4">
<p id="bio-name">{name}</p>
{#each routes as item} {#each routes as item}
{#if item.inactive ?? false} <a
<p class="navbar-link" class="nav-link"
>{item.name}</p> class:current-link={isCurrentLink($page.url.pathname, item.route)}
{:else} class:disabled-link={item.disabled}
<a class="navbar-link" href={item.route}>{item.name}</a>
class:active-link={isActiveLink($page.url.pathname, item.route)}
href={item.route}>{item.name}</a>
{/if}
{/each} {/each}
</div> </div>
<style> <style>
#bio-name {
font-family: serif;
font-size: 3rem;
font-weight: normal;
color: theme('colors.java.800');
}
#navbar { #navbar {
display: flex; display: flex;
flex-direction: column; flex-direction: column;

View File

@ -0,0 +1,17 @@
<script lang="ts">
import { blur } from 'svelte/transition'
export let url: string
</script>
{#key url}
<div class="transition" in:blur>
<slot />
</div>
{/key}
<style>
.transition {
height: 100%;
}
</style>

View File

View File

@ -0,0 +1,64 @@
<script lang="ts">
import type { PageData } from './$types';
export let data: PageData;
const { posts, pageN, maxPageN } = data;
import siteMetadata from '$content/metadata.json';
import { DoubleArrowLeft, DoubleArrowRight, ChevronLeft, ChevronRight } from 'svelte-radix';
import PostHeader from '$lib/components/PostHeader.svelte';
const disableLinkPrev = pageN == 1;
const disableLinkNext = pageN == maxPageN;
</script>
<svelte:head>
<title>Page {pageN } | {siteMetadata.blogName}</title>
</svelte:head>
<hr class="separator" />
<ul id="catalog" class="content">
{#each posts as post}
<li>
<PostHeader metadata={post.meta} link={post.path} />
</li>
{/each}
</ul>
<hr class="separator" />
<div id="page-navigator">
<a
class="nav-link icon"
class:disabled-link={disableLinkPrev}
href="/page/1"><DoubleArrowLeft /></a>
<a
class="nav-link icon"
class:disabled-link={disableLinkPrev}
href="/page/{Math.max(1, pageN-1)}"><ChevronLeft /></a>
<div id="page-map">
<p id="page-num">{pageN}/{maxPageN}</p>
</div>
<a
class="nav-link icon"
class:disabled-link={disableLinkNext}
href="/page/{Math.min(maxPageN, pageN+1)}"><ChevronRight /></a>
<a
class="nav-link icon"
class:disabled-link={disableLinkNext}
href="/page/{maxPageN}"><DoubleArrowRight /></a>
</div>
<style>
#page-navigator {
display: flex;
flex-direction: horizontal;
justify-content: space-between;
margin: 1em auto 1em auto;
width: 80%;
align-items: center;
}
#page-map {
display: inline-block;
}
#page-num {
color: rgb(128,128,128);
}
</style>

View File

@ -0,0 +1,22 @@
import { error } from '@sveltejs/kit';
import type { PageLoad } from './$types';
import { getPosts } from '$lib/posts';
import siteMetadata from '$content/metadata.json';
export const load: PageLoad = async ({ params }) => {
const pageN: number = +params.slug;
if (!pageN) throw error(404);
const posts = await getPosts();
const pageSize = siteMetadata?.pageSize || 3;
const maxPageN = Math.ceil(posts.length / pageSize);
if (pageN < 0 || pageN > maxPageN) throw error(404);
return {
pageN,
posts: posts.slice((pageN - 1) * pageSize, pageN * pageSize),
maxPageN,
};
}

View File

@ -1,30 +1,7 @@
import type { PageLoad } from './$types'; import type { PageLoad } from './$types';
import { getPosts } from '$lib/posts';
export const load: PageLoad = async (_) => { export const load: PageLoad = async (_) => {
const allPostFiles = import.meta.glob('$content/post/*.md'); const allPosts = await getPosts();
const iterablePostFiles = Object.entries(allPostFiles); return { allPosts };
const allPosts = await Promise.all(
iterablePostFiles.map(async ([pathMarkdown, resolver]) => {
const { metadata } = await resolver();
const pathPost = pathMarkdown.slice(pathMarkdown.lastIndexOf("/") + 1, -".md".length);
return {
meta: {
...metadata,
date: new Date(metadata.date),
},
path: pathPost
};
})
);
allPosts.sort((post1, post2) => {
const date1: Date = post1.meta.date;
const date2: Date = post2.meta.date;
return date2.getTime() - date1.getTime();
});
return {
allPosts
};
}; };

View File

@ -3,7 +3,7 @@
export let data: PageData; export let data: PageData;
const { allPosts } = data; const { allPosts } = data;
import siteMetadata from '$content/metadata.json'; import siteMetadata from '$content/metadata.json';
import Heading from './Heading.svelte'; import PostHeader from '$lib/components/PostHeader.svelte';
</script> </script>
<svelte:head> <svelte:head>
@ -14,7 +14,7 @@
<ul id="catalog" class="content"> <ul id="catalog" class="content">
{#each allPosts as post} {#each allPosts as post}
<li> <li>
<Heading metadata={post.meta} link={post.path} /> <PostHeader metadata={post.meta} link={post.path} />
</li> </li>
{/each} {/each}
</ul> </ul>

View File

@ -3,14 +3,14 @@
export let data: PageData; export let data: PageData;
const { metadata, Content } = data; const { metadata, Content } = data;
import siteMetadata from '$content/metadata.json'; import siteMetadata from '$content/metadata.json';
import Heading from '../Heading.svelte'; import PostHeader from '$lib/components/PostHeader.svelte';
</script> </script>
<svelte:head> <svelte:head>
<title>{metadata.title} | {siteMetadata.blogName}</title> <title>{metadata.title} | {siteMetadata.blogName}</title>
</svelte:head> </svelte:head>
<Heading metadata={metadata}/> <PostHeader metadata={metadata}/>
<hr /> <hr />
<article class="prose lg:prose-xl dark:prose-invert p-4"> <article class="prose lg:prose-xl dark:prose-invert p-4">

View File

@ -0,0 +1,7 @@
import type { PageLoad } from './$types';
import { getTags } from '$lib/posts';
export const load: PageLoad = async (_) => {
const allTags = await getTags();
return { allTags };
};

View File

@ -0,0 +1,27 @@
<script lang="ts">
import type { PageData } from './$types';
export let data: PageData;
const { allTags } = data;
import siteMetadata from '$content/metadata.json';
</script>
<svelte:head>
<title>Tags | {siteMetadata.blogName}</title>
</svelte:head>
<h1>Tags</h1>
<hr class="separator" />
<div id="catalog" class="content">
{#each allTags as tag}
<a class="tag" href="/tag/{tag}">{tag}</a>
{/each}
</div>
<hr class="separator" />
<style>
.tag {
margin-left: 1em;
margin-right: 1em;
}
</style>

View File

@ -0,0 +1,29 @@
<script lang="ts">
import type { PageData } from './$types';
export let data: PageData;
const { name, posts } = data;
import siteMetadata from '$content/metadata.json';
import PostHeader from '$lib/components/PostHeader.svelte';
</script>
<svelte:head>
<title>{name} | {siteMetadata.blogName}</title>
</svelte:head>
<h1>{name}</h1>
<hr class="separator" />
<ul id="catalog" class="content">
{#each posts as post}
<li>
<PostHeader metadata={post.meta} link={post.path} />
</li>
{/each}
</ul>
<hr class="separator" />
<style>
#catalog li {
margin-bottom: 10px;
}
</style>

View File

@ -0,0 +1,10 @@
import type { PageLoad } from './$types';
import { getPosts } from '$lib/posts';
export const load: PageLoad = async ({ params }) => {
const posts = await getPosts(params.slug);
return {
name: params.slug,
posts,
};
}

View File

@ -40,6 +40,12 @@ const config = {
precompress: false, precompress: false,
trailingSlash: 'always', trailingSlash: 'always',
}), }),
prerender: {
crawl: true,
entries: [
"/page/1/",
],
},
alias: { alias: {
$content: contentDir, $content: contentDir,
"@/*": "./*", "@/*": "./*",

7
tests/post.spec.ts Normal file
View File

@ -0,0 +1,7 @@
import { expect, test } from '@playwright/test';
test('Navigate to blog post', async ({ page }) => {
await page.goto('/post');
await page.getByText('The Perfect Math Class').click();
await expect(page).toHaveURL("/post/the-perfect-math-class/");
});