Markdown Blog with Sveltekit

Published: 2024-09-14


Sometime last month, I managed to purchase this domain from namecheap and I wanted to make a portfolio website along with a blog. To make a blog there are various frameworks that you can use. I ended up choosing SvelteKit. This article doesn’t cover the basics of sveltekit, it’s more about how to create a blog that can parse markdown files. If you want to learn how to make a website with svelte, the svelte interactive tutorial is a great place.

Why Sveltekit?

I had considered a few options for how I wanted to make this website. Here’s some of them and why I chose not to use them

  • Vanilla HTML/CSS and Javascript: This is probably the easiest and simplest way to make a website and I was initially planning on doing this. But soon I got tired of repeating myself and I wanted to use reuseable components

  • Hugo is very popular among blog makers. Hugo lets you write your content in markdown and it compiles them to html/css. There is a huge library of themes for hugo that you can use to make your website look pretty. But I found hugo to be too rigid. It’s my portfolio, I wanted to make it look exactly the way I like.

  • ReactJS + Gatsby also seemed to be a good option, but I reckoned it was too much to learn and instead I opted to stick with what I already knew.

Svelte has a really nice syntax where you put the markup, styles and scripts for components in the same file. And it can also be compiled to a static site which is what I wanted to do.

Making a static site

Sveltekit supports file system routing, i.e. the url for a page is determined by its location in the file system.

Refer to this for making a basic website with sveltekit.

Sveltekit also supports various adapters for easy deployment. What I’m interested in is called adapter-static. Adapter-static lets you build your website into a static site (if possible). Static sites are great since they’re really easy to deploy and can be hosted for free on many different platforms. This site is hosted on github pages.

Markdown

I want to be able to write my posts in markdown instead of having to write svelte/html for every post. For this I used Mdsvex. It allows you to write your svelte pages in markdown.

First we install mdsvex

npm i -D mdsvex

Next, we need to add mdsvex to our config file svelte.config.js

import adapter from '@sveltejs/adapter-static';
import { mdsvex } from 'mdsvex';

export default {
    kit: {
        adapter: adapter({
            pages: 'build',
            assets: 'build',
            fallback: undefined,
            precompress: false,
            strict: true
        }),
    },
    extensions: ['.svelte', '.md'],
    preprocess: [mdsvex ({
        extensions: ['.md']
    })]
};
  • extensions is a list of file extensions that Svelte will treat as components

  • mdsvex() function preprocesses markdown to html. By default it only handles .svx files so we tell it to use .md instead.

Writing pages with markdown

Using mdsvex, we can render markdown files as svelte pages.

Create a file src/blog/first/+page.md and type some markdown in it

---
title: First Post
date: "2024-09-14"
---

# Heading 1
# Heading 2

- Item 1
- Item 2

```python
for i in range(100):
    print("Hello world")

Now start the dev server (npm run dev) and visit /blog/first

And just like that you have made a page with just markdown.

Here, the title and date at the top of the file are called frontmatter. It holds some meta-data about the post that we will access later.

You may notice a lack of syntax highlighting for code. Mdsvex comes with prismJS installed. Just select a theme and download its css file, then import it in the script section of your svelte file.

Adding images

So let’s go ahead and save an image called apple.jpg in src/routes/blog/first/images.

Then I’ll modify my markdown to include

![An apple](./images/apple.jpg)

If you visit /blog/first you’ll see that there’s no image. The server returns 404 for /blog/first/images/apple.jpg.

That’s because in Svelte all images must be served from static/ (or sometimes src/lib). When the server gets a request for /blog/first/images/apple.jpg, it looks for static/blog/first/images/apple.jpg.

You can solve this by putting all your images in static/ but I want to save my images next to my markdown. We can fix this using the mdsvex-relative-images package. Install it using

npm i -D mdsvex-relative-images

We need to modify our mdsvex config to include this. Open svelte.config.js and add the following

import relativeImages from "mdsvex-relative-images";

We need to pass this to the mdsvex function as a remark plugin.

    preprocess: [mdsvex ({
        extensions: ['.md'],
        remarkPlugins: [relativeImages]
    })]

Now if you open blog/first you will see that there is an image.

Dynamic routes

I do not want to store all my mardown files and their images in src/blog. I want some freedom as to where I store them. In my case, I’m storing them at src/blog-posts. For this reason, we use dynamic routes.

Create a folder called [slug] in src/blog. slug represents the name of the post. For example if a client requests blog/first, here slug = first.

Inside the [slug] folder create a +page.svelte file and put <h1> Hello World <h1/> in it.

Now start the server and visit /blog/blogname and you’ll see that it says Hello World. You can change ‘blogname’ to any string and it will always return the same page.

This is nice, but I want to be able to access the slug, “blogname” in this case so that I can find the corresponding markdown file and populate the page.

Every route in svelte can have a corresponding +page.js file. If we export a function called load in this file, it will be called before the page is rendered. The load function is responsible for loading any data required for the page and pass it to the page as an object.

So create a file src/route/blog/[slug]/+page.js and insert the following in it.

export async function load({params}) {
    const post = await import(`../../../blog-posts/${params.slug}/post.md`);

    const {title, date} = post.metadata;
    const content = post.default;

    return {
        title,
        date,
        content
    }
}

A few things to note about this function

  • We first import the corresponding markdown file using import(). Note that import() can only take relative path.

  • The markdown file is at src/blog-posts/<name of the post>/post.md. In this case params.slug represents the name of the post. (This is the name that appears in the url)

  • I have changed the name of the markdown file from +page.md to post.md. This isn’t necessary, I’ve done so just to show that the file can be called whatever you want. Just be sure to use the correct name in the above code.

  • We then destructure the post to extract the title, date and content and we return these as an object. Here title and date are from the markdown frontmatter.

  • The object returned by the load() function is available in +page.svelte as a prop called data.

Now we need to use the data returned by load to populate the page in src/routes/blog/[slug]/+page.svelte .

<script>
    export let data;
</script>

<div>
    <h1>{data.title}</h1>
    <p>Published: {data.date}</p>

    <svelte:component this={data.content} />
</div>
<style>
    div :global(img) {
        display: block;
        margin: 0 auto;
        width: min(100%, 500px)
    }
</style>

I’m using svelte:component to render the content of the page. I’ve also added some styles using the https://svelte.dev/docs/svelte-components#style to prevent the image from appearing too big. I have added other styles here as well, but for the sake of brevity they’re not mentioned here.

Now if you visit /blog/first, you will see:

Entries function

I’ve said that I wanted to build my blog as a static site. Now let’s try that out. Run npm run build to create a build.

The build fails with the error

Error: The following routes were marked as prerenderable, but were not prerendered because they were not found while crawling your app:
 - /blog/[slug]

This is because for svelte to prerender all the pages in /blog/, it needs to know what values of [slug] are possible at build time itself. This is possible through the entries() function in +page.server.js. entries() function returns a list of possible paths to be served by the dynamic route.

Instead of hard-coding it, we loop through our folder containing posts to programmatically create this list. Create a file src/routes/blog/[slug]/+page.server.js

import fs from 'fs';
import path from 'path';

export function entries() {
    const contentDir = path.join(process.cwd(), 'src/blog-posts');
    const files = fs.readdirSync(contentDir);

    const posts = []
    for (const file of files) {
        const fullPath = path.join(contentDir, file);
        if (fs.statSync(fullPath).isDirectory()) {
            posts.push(file);
        }
    }

    return posts.map(post => ({ slug: post }));
}

Here we’re looping through all the folders inside src/blog-posts, where the name of the folder represents the name of the post. Then the function returns an array like [{slug: "first"}, {slug: "second"}] where “first” and “second” are two different posts.

Now build, it does not throw an error.

Trailing slash

Now that we’ve built a static site, let’s try hosting it for testing purposes. You can use any http server, I’m using http.server module that comes installed with python.

Run python -m http.server -d build 8000. This starts a http server at the build directory on port 8000.

Now visit localhost:8000/ and you’ll see the home page of your site (assuming you created one).

But if you visit /blog/first it will return 404. To fix this:

Open src/routes/+layout.js and add the following line to it.

export const trailingSlash = 'always';

Now it builds correctly and returns the blog post for /blog/first.

I don’t want to store my posts in the same repo as my code

Currently all our blog posts are in src/blog-posts. This means that all our markdown files are part of our git repository. I would like to have the post content in a separate repository for two reasons

  • Having the content separately allows me to use the same markdown with other markdown readers. The content of the posts are not tied down along with the website code

  • I want some control over what people see and how they see it. I don’t want my markdown files visible to the public

The solution is to have the posts be part of a private submodule.

Git submodule

Before working with submodules it’s usually good to set submodule.recurse to true. This makes git pull pull the submodules as well by default.

git config --global submodule.recurse true

Now create a github repo (I’m using github but you can use any remote).

Let’s say my repo is at github.com/shishiraiyar/blog-posts. First ensure that the remore repo has atleast one commit.

git submodule add https://github.com/shishiraiyar/blog-posts.git ./src/blog-posts

Now if you run git status you’ll see that two new files have been created

        new file:   .gitmodules
        new file:   src/blog-posts

I would like to point out that the submodule is just like a regular git repository. To make changes to the submodule (to add posts), you can cd to its directory and run normal git commands (add, commit, push etc).

Any commits to the submodule must be followed up a commit in the main repo to let the main repo know that the submodule has changed. If this commit isn’t made, when somebody else clones the repo, they’ll end up with the older commit of the submodule

Now if somebody else wants to clone your repo, they must also run the following to get the contents of the submodule. (Assuming they have access to the submodule)

git submodule init
git submodule update

Having a private submodule

I like to keep this submodule private. There is one caveat to this. Since my submodule is private, github actions doesn’t have access to it, and hence I can’t use it to automate my build process.

I can build locally and push the built files to another repo, but I would rather not do that.

I want to give access to my private submodule to Github Actions. I’ve covered how to do that in another article.

Index page

We’re almost done with the blog. All it needs now is an index page where all the articles are listed. As expected, the code for this goes in src/routes/blog/+page.svelte. But before that we need to pass a list of posts to this as a prop. As before we use the load() function.

Create a file src/routes/blog/+page.server.js

import fs from 'fs';
import path from 'path';

export async function load() {
    const contentDir = path.join(process.cwd(), 'src/blog-posts');
    const files = fs.readdirSync(contentDir);

    const posts = []
    for (const file of files) {
        const fullPath = path.join(contentDir, file);
        if (fs.statSync(fullPath).isDirectory()) {
            const post = await import(`../../blog-posts/${file}/post.md`);
            posts.push({
                name: file,
                title: post.metadata.title,
                date: post.metadata.date,
                link: `/blog/${file}`
            });
        }
    }
    posts.sort((a, b) => new Date(b.date) - new Date(a.date))
    return {posts}    
}

As before, we loop through all the folders in blog-posts, extract metadata from the markdown file contained within, and return it. Note that the load function here is in +page.server.js unlike before where we had put it in +page.js. This is because here we need to access the file system which can only be done on the server.

Before returning the posts, we sort it by date with latest first.

Now we use this data in src/routes/blog/+page.svelte

<script>
    export let data;
</script>

{#each data.posts as post}
    <div>
        <a href={post.link}>{post.title}</a>
        <p>Published: {post.date}</p>
    </div>
{/each}

And that’s pretty much it. Other than this I’ve added some minor styles to this page. In the future I plan on implementing post tags and the ability to list posts by tag.

Sources