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 componentsmdsvex()
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 thatimport()
can only take relative path.The markdown file is at
src/blog-posts/<name of the post>/post.md
. In this caseparams.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
topost.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 calleddata
.
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.