Table of Contents
Preface
As I mentioned in my first post, I decided to go with SvelteKit, because my website is based on it. So it was a no-brainer. I know there are many ready solutions. Like Eleventy, for example, if you want a blog or just a static website. But in this case, I would have had to integrate it somehow into my current setup: do some complex routing with redirects or move the blog to a subdomain and create a custom theme for it. I don't have too much free time for things like this, so just updating the routing of my website felt like the quickest way to add the blog. And it actually was. I only spent several evenings setting things up. Styling was already 90% done, so I just had to implement the data part.
Prerequisites
The setup is super-minimal:
- SvelteKit app
- a dedicated repo or whatever hosting solution, where the markdown posts are gonna be stored
Post storage
This part depends on the approach. If the whole app is gonna be just the blog, then I guess you can simply store the posts in the same repo and there's no need to fetch anything. I didn't want to pollute my repo with the blog posts. If there's an easy way to have things decoupled from the start, I do it. So I don't have to deal with it later if it becomes an issue. And actually, the posts processing stays the same, so having your data somewhere else simply adds one more step.
One of the weird things I faced storing my posts on GitLab was that fetching the list of repo files didn't give me the creation time of the posts. So in order to sort them by date I had to download them all and use the date that I add myself in the post metadata.
Routing
So I need two routes:
- paginated list of posts
- the post page
I have my blog on the /blog route and my posts on the /blog/post-slug. For the pagination, I decided to go with /blog/p/page-number, where p is short for pages. It's the one constraint I have here. With this setup I can't have a post with the p slug. I can live with it. And I didn't go with the /blog?page=page-number approach, because if I understood correctly, SvelteKit can't generate pages based on the search parameters. Because they can be anything, and it seems like there's no way to use them the same way as dynamic route parameters. Maybe there's a way to make an explicit list of all the pages with search parameters, but after a quick read of what I found on the topic, I decided not to invest too much time into this and just go with what was quicker.
Now the routing looks like this:
/blog
/p
/page-number
/post-slug
I also need to handle errors in case I land on a non-existent route: wrong post list page or wrong slug. I opted for the redirects. It's faster to implement and arguably provides a better UX. I don't have to click anything, I just land on the initial blog page. So if somebody lands on /blog/p/non-existent-page or /blog/non-existent-slug, they will be redirected to /blog/p/1. Also, the parent route /blog needs to be a redirect to /blog/p/1. Or you can render there the same first page. I guess it's a matter of taste and maybe SEO.
Here's my file structure:
routes
blog
+page.server.ts
[slug]
+page.server.ts
+page.svelte
p
+page.server.ts
[page]
+page.server.ts
+page.svelte
Svelte pages
I don't show here the actual pages layouts, because it's completely up to you. Render it however you want.
/blog
// /blog/+page.server.ts
// Simply does the redirect to the initial blog page
import { redirect } from '@sveltejs/kit';
export function load() {
throw redirect(308, '/blog/p/1');
}
/blog/p
// /blog/p/+.page.server.ts
// Same here. I don't have the /blog/p route, so I need to do the redirect to the initial blog page
import { redirect } from '@sveltejs/kit';
export function load() {
throw redirect(308, '/blog/p/1');
}
/blog/p/[page]
// /blog/p/[page]/+page.server.ts
import { BLOG_PAGE_SIZE } from '../../constants';
import type { EntryGenerator } from './$types';
import type { IPageServer } from './types';
import allPostFilesData from '$lib/utils/allPostFilesData';
import { redirect } from '@sveltejs/kit';
import postFilesToMetas from '$lib/utils/postFilesToMetas';
import { PUBLIC_GITLAB_HOSTNAME } from '$env/static/public';
export async function load({ params }: IPageServer) {
const { page } = params;
const currentPage = parseInt(page || '1', 10);
// Here I fetch all the files from my blog repo.
// This fetcher is reused in the vite.config.ts
// to generate the RSS xml file, so in order
// not to hardcode the repo hostname on each call
// I just pass it as an argument. And at this point
// the env doesn't have this variable, not sure why.
// I guess it's how SvelteKit injects these variables.
const postFileList = await allPostFilesData({ hostname: PUBLIC_GITLAB_HOSTNAME });
// If the page number is incorrect in any way,
// I do the redirect to the initial blog page.
if (isNaN(currentPage) || currentPage > Math.ceil(postFileList.length / BLOG_PAGE_SIZE)) {
throw redirect(308, '/blog/p/1');
}
const startIndex = (currentPage - 1) * BLOG_PAGE_SIZE;
const endIndex = startIndex + BLOG_PAGE_SIZE;
// Here having all the available files from the repo,
// I fetch the actual posts and get the meta from them
// to do the sorting. Again, because this is reused
// in the vite.config.ts to generate RSS xml,
// I have to pass the hostname.
const metasSorted = await postFilesToMetas({ files: postFileList, hostname: PUBLIC_GITLAB_HOSTNAME });
const postMetas = metasSorted.slice(startIndex, endIndex);
return {
metas: postMetas,
prevPage: currentPage - 1 > 0 ? currentPage - 1 : null,
nextPage: metasSorted[endIndex] ? currentPage + 1 : null,
}
}
// This entries array tells SvelteKit which pages I need
// to pre-render so they are served with all the content.
export const entries: EntryGenerator = async () => {
const postFileList = await allPostFilesData({ hostname: PUBLIC_GITLAB_HOSTNAME });
const pageCount = Math.ceil(postFileList.length / BLOG_PAGE_SIZE);
return (new Array(pageCount)).fill(undefined).map((_item, index) => ({ page: `${index + 1}` }));
};
export const prerender = true;
// /blog/p/[page]/+page.svelte
// Here you can do whatever you need with the data you get
// from the server-side code
<script lang="ts">
// The IPage type is what I want the server code to give me here
// In this case it's { metas: IMeta[], prevPage: number | null, nextPage: number | null }
import type { IPage } from './types';
let { data }: IPage = $props();
</script>
<WhateverComponent { ...data } />
/blog/[slug]
// /blog/[slug]/+page.server.ts
import { redirect } from "@sveltejs/kit";
import type { IPageServer } from "./types";
import allPostFilesData from "$lib/utils/allPostFilesData";
import postFilesToPostsWithMeta from "$lib/utils/postFilesToPostsWithMetaSorted";
import type { EntryGenerator } from "./$types";
import filenameToSlug from "$lib/utils/filenameToSlug";
import { PUBLIC_GITLAB_HOSTNAME } from '$env/static/public';
export async function load({ params }: IPageServer) {
const { slug } = params;
if (!slug) {
throw redirect(308, '/blog/p/1');
}
const postFileList = await allPostFilesData({ hostname: PUBLIC_GITLAB_HOSTNAME });
const fileRecord = postFileList.find(item => item.name.replace('.md', '') === slug);
if (!fileRecord) {
throw redirect(308, '/blog/p/1');
}
const postsSorted = await postFilesToPostsWithMeta(postFileList);
const postIndex = postsSorted.findIndex(item => item.meta.slug === slug);
if (postIndex < 0) {
throw redirect(308, '/blog/p/1');
}
// Here I get the previous and next posts chronologically.
// Just in case somebody wants to check out these posts,
// without having to go to the post list.
const prevPost = postsSorted[postIndex - 1];
const nextPost = postsSorted[postIndex + 1];
return {
post: postsSorted[postIndex],
prevPostPartialMeta: prevPost ? { slug: prevPost.meta.slug, title: prevPost.meta.title } : null,
nextPostPartialMeta: nextPost ? { slug: nextPost.meta.slug, title: nextPost.meta.title } : null,
}
}
// Here again the entries array contain all the available slugs
// to pre-render all the post pages.
export const entries: EntryGenerator = async () => {
const postFileList = await allPostFilesData({ hostname: PUBLIC_GITLAB_HOSTNAME });
return postFileList.map(item => ({ slug: filenameToSlug(item.name) }));
};
export const prerender = true;
// /blog/[slug]/+page.svelte
<script lang="ts">
// The IPage here is { post: IPost, nextPostPartialMeta: Pick<IMeta, 'slug' | 'title'>, prevPostPartialMeta: Pick<IMeta, 'slug' | 'title'> }
import type { IPage } from './types';
let { data }: IPage = $props();
</script>
<WhateverComponent { ...data } />
Processing data
Here are the functions that I used to get and process posts. Nothing really to explain here, just showing the way it works and which tools are used.
GitLab repo file list fetcher:
// This one is to fetch all the available posts in my GitLab repo.
interface IFullPostFileList {
hostname: string;
posts?: IGitLabFile[];
url?: string;
}
import parseLink from 'parse-link-header';
const allPostFilesData = async (props: IFullPostFileList) => {
const response = await fetch(props?.url || `${props?.hostname}/${BLOG_REPO_ID}/repository/tree?pagination=keyset&order_by=id&sort=asc&per_page=${BLOG_PAGE_SIZE}`)
const link = parseLink(response.headers.get('Link'));
const filellist = await response.json() as IGitLabFile[];
if (link?.next?.url) {
return await allPostFilesData({ ...props, posts: (props?.posts || []).concat(filellist), url: link.next.url });
}
return (props?.posts || []).concat(filellist);
}
Files fetcher, sorter and meta extractor:
interface IPostFilesToMetas {
files: IGitLabFile[];
hostname: string;
}
import fm, { type FrontMatterResult } from 'front-matter';
const postFilesToMetas = async ({ files, hostname }: IPostFilesToMetas) => {
if (files.length === 0) {
return [];
}
const postUrls = files.map(item => `${hostname}/${BLOG_REPO_ID}/repository/files/${item.path}/raw`);
const markdownPosts = await Promise.all(postUrls.map(async url => (await fetch(url)).text()));
const postMetas: IPostMeta[] = markdownPosts.map((markdown, index) => ({ ...(fm(markdown) as FrontMatterResult<Omit<IPostMeta, 'slug'>>)['attributes'], slug: filenameToSlug(files[index].name) }));
const metasSorted = postMetas.sort((a, b) => (new Date(b.createdAt)).getTime() - (new Date(a.createdAt)).getTime());
return metasSorted;
}
Files fetcher, sorter and converter to posts with meta:
import fm, { type FrontMatterResult } from 'front-matter';
import { marked } from "marked";
const postFilesToPostsWithMeta = async (files: IGitLabFile[]) => {
if (files.length === 0) {
return [];
}
const postMetas: Omit<IPostMeta, 'slug'>[] = [];
const preprocess = (markdown: string) => {
const { attributes, body }: FrontMatterResult<Omit<IPostMeta, 'slug'>> = fm(markdown);
postMetas.push(attributes);
return body;
}
marked.use({ hooks: { preprocess }});
const postUrls = files.map(item => `${PUBLIC_GITLAB_HOSTNAME}/${BLOG_REPO_ID}/repository/files/${item.path}/raw`);
const markdownPosts = await Promise.all(postUrls.map(async url => (await fetch(url)).text()));
const parsedPosts = markdownPosts.map(markdown => marked(markdown));
const postsWithMeta = parsedPosts.map((html, index) => ({ meta: { ...postMetas[index], slug: filenameToSlug(files[index].name) }, content: html }));
const postsSorted = postsWithMeta.sort((a, b) => (new Date(b.meta.createdAt)).getTime() - (new Date(a.meta.createdAt)).getTime());
return postsSorted;
}
RSS
For generating the RSS feed I used the package rss. I added this code to the vite.config.ts to the targets array of the viteStaticCopy plugin.
// vite.config.ts
import RSS from "rss";
{
src: 'package.json', // this is a placeholder string, so we have this script to be executed
dest: '',
rename: 'feed.xml',
transform: {
encoding: 'buffer',
handler: async (_content, path) => {
const blogHostname = '<posts-stprage-hostname>';
const hostname = '<blog hostname>'
try {
const postFileList = await allPostFilesData({ hostname: blogHostname });
const metasSorted = await postFilesToMetas({ files: postFileList, hostname: blogHostname });
const feedGenerator = new RSS({
title: "<blog title>",
description: "<blog description>",
site_url: `${hostname}/blog`,
feed_url: `${hostname}/feed.xml`,
image_url: `${hostname}/meta-image.png`,
pubDate: new Date(),
});
metasSorted.forEach((post) => {
feedGenerator.item({
title: post.title,
description: post.summary,
url: `${hostname}/blog/${post.slug}`,
date: post.createdAt,
});
});
return feedGenerator.xml();
} catch (e) {
// This is here to debug in case something fails while the building stage.
console.log(path);
console.log(e);
return null;
}
}
}
}
That's it
So here it is. A very simple blog setup. It can be easily extended to have tags, hero images and whatever you can think of. One thing I'm already thinking of is adding a pinned flag to the metadata, so I can have pinned posts in the post list.
