MDSveX and Svelte Kit

A few weeks ago, I replaced the markdown-based blog setup that was previously written about. It was due for a make-over with the upgrade from Sapper to Svelte Kit. Some of the dependencies was used to make that setup work, like the code highlighter, is poorly modularized. It exists in the global scope, making it a bad fit for Svelte Kit which relies heavily on ESM’s import/export for tree shaking and other goodies.

I was excited to explore how I could solve things better. Through experimentation and a lot of help from the Svelte Discord server, I think I’ve settled on something pretty darn good.

Enter MDSveX

MDSveX is Svelte in Markdown. It allows you to write Markdown files that contain Svelte components. It’s also a lot more! It is an extensible markdown parser — and I’ve found it integrates really nicely with Svelte Kit despite not even being designed for it.

MDSveX comes with the stuff you need from a markdown parser:

  • A code highlighter automatically highlights code blocks, supporting most languages out of the box
  • It hooks into the remark and rehype ecosystems, which let you add custom parsing logic
  • Front-matter is automatically parsed as metadata, letting you pull it out by importing the component (more on this later)

A typical MDSveX component is defined with the .svx extension. It primarily contains Markdown, but it also allows all the Svelte syntax like <script> blocks and {curly_braces}.

---
title: My cool counter
date: 2021-05-20
summary: A cool counter I made
---

<script>
  import Count from "./Count.svelte"
</script>

# {title}

Ever want to increment a number? Now you can!

<Counter />

My cool counter

Ever want to increment a number? Now you can!

0

Pretty neat, right? Note how the {title} was referenced in the markup. The data defined in the front matter can also be accessed as variables in the component.

Because Svelte Kit renders components in src/routes as pages, this file would be rendered as a page as well. For example, if you have a file at src/routes/blog/my-cool-counter.svx this would result as a page at /blog/my-cool-counter.

Configuration and plugins

For Svelte Kit, MDSveX plugs in as a preprocessor. MDSveX then transforms all .svx files to regular components.

Here’s a minimal example:

/* svelte.config.js */

import { mdsvex } from "mdsvex"
import sveltePreprocess from "svelte-preprocess"

/** @type {import('@sveltejs/kit').Config} */
export default {
	// Pick up both .svelte and .svx files
	extensions: [".svelte", ".svx"],

	// Run mdsvex transformations, then svelte-preprocess
	preprocess: [mdsvex(), sveltePreprocess()]
}

You can further enhance your output by adding remark and rehype plugins to modify the AST before outputting as HTML. For example, here’s a sample of the processing on this blog:

/* svelte.config.js */

import { mdsvex } from "mdsvex"
import abbr from "remark-abbr"
import urls from "rehype-urls"
import slug from "rehype-slug"
import autoLinkHeadings from "rehype-autolink-headings"
import addClasses from "rehype-add-classes"

function processUrl(url, node) {
	if (node.tagName === "a") {
		node.properties.class = "text-link"

		if (!url.href.startsWith("/")) {
			// Open external links in new tab
			node.properties.target = "_blank"
			// Fix a security concern with offsite links
			// See: https://web.dev/external-anchors-use-rel-noopener/
			node.properties.rel = "noopener"
		}
	}
}

/** @type {import('@sveltejs/kit').Config} */
export default {
	extensions: [".svelte", ".svx"],

	preprocess: mdsvex({
		plugins: {
			remarkPlugins: [abbr], // adds support for footnote-like abbreviations
			rehypePlugins: [
				figure, // convert images into <figure> elements
				[urls, processUrl], // adds rel and target to <a> elements
				slug, // adds slug to <h1>-<h6> elements
				[autoLinkHeadings, { behavior: "wrap" }], // adds a <a> around slugged <h1>-<h6> elements
				[addClasses, { "ul,ol": "list" }] // add classes to these elements
			]
		}
	})
}

Layouts

Just like how __layout.svelte wraps around Svelte Kit pages, MDSveX has a way to define layouts for .svx files. These are normal Svelte components with a <slot>. The slot being where the article goes.

<!-- src/routes/blog/layout.svelte -->
<script>
	// These props get filled in from the page's front matter
	export let title
	export let coverImageUrl
</script>

<svelte:head>
	<title>{title}</title>
	<meta property="og:title" content={title} />
	<meta property="og:image" content={coverImageUrl} />
</svelte:head>

<article>
	<h1>{title}</h1>
	<slot />
	<a href="/blog">Back to blog index</a>
</article>
/* svelte.config.js */

import { mdsvex } from "mdsvex"

/** @type {import('@sveltejs/kit').Config} */
export default {
	extensions: [".svelte", ".svx"],

	preprocess: mdsvex({
		// This is where the layouts are defined
		layout: {
			blog: "./src/routes/blog/layout.svelte",
			projects: "./src/routes/projects/layout.svelte"
		}
	})
}

MDSveX will try to guess which layout should be used based on the filename. If you have a a blog post at src/routes/blog/my-post.svx, this would use the blog layout because the string blog exists in the filename.

You can also force the layout by defining it in the front matter:

---
layout: article
title: Article layout example
coverImageUrl: https://example.com/path/to/my/image.png
---

I will always use the `article` layout!

Despite having a lot in common, MDSveX layouts currently work as a separate system from Svelte Kit’s own __layout.svelte. As I understand this is due to technical limitation. It would be cool if MDSveX and Svelte Kit’s layouts could be unified in the future. 🐧

Rendering a list of blog posts

In order for users to find your blog post, we can provide a list of the posts on our site. By utilizing Vite’s import.meta.glob, we can automatically grab all the .svx files in the current directory. These files can be imported as modules, and we can collect metadata from them. Finally, we serve the payload as a Svelte Kit endpoint.

/* src/routes/blog/posts.json.js */

import pMap from "p-map"
import { basename } from "path"

export async function get() {
	// Import all .svx files in the directory
	const modules = import.meta.glob("./*.svx")

	// Run a map over each module

	// Check out the docs for p-map if this looks confusing, it's  basically
	// Array.map(...) but for promises
	const posts = await pMap(
		Object.entries(modules),
		async function ([filename, module]) {
			// Import the component. The metadata here is added by MDSveX and mirrors
			// the front matter.
			const { metadata } = await module()

			return {
				title: metadata.title,
				date: new Date(metadata.date),
				summary: metadata.summary,
				slug: basename(filename, ".svx") // Generate a slug we can link to
			}
		}
	)

	// Sort posts by descending date
	posts.sort((a, b) => (a.date > b.date ? -1 : 1))

	return {
		body: { posts }
	}
}

On requesting GET /blog/posts.json it would return this JSON:

{
	"posts": [
		{
			"title": "So I tried out MDSveX on Svelte Kit",
			"date": "2021-05-19T00:00:00.000Z",
			"summary": "And not too surprisingly I really like it",
			"slug": "svelte-kit-mdsvex"
		},
		{
			"title": "Crafting the portfolio site",
			"date": "2021-02-26T00:00:00.000Z",
			"summary": "Updated portfolio website for 2021. Built with Sapper and Svelte.",
			"slug": "portfolio-site-2021"
		},
		{
			"title": "Reimagining Type Kana",
			"date": "2020-08-02T00:00:00.000Z",
			"summary": "A quiz app to help you learn hiragana and katakana, the Japanese syllabaries.",
			"slug": "type-kana"
		}
	]
}

The data could then be fetched in a loading function, and provided to a page to render this as a list of blog posts.

Pain points

TypeScript

If you’re familiar working with TypeScript in Svelte, you’ll unfortunately have to skip it when it comes to the MDSveX parts of your application. MDSveX does not play nice with svelte-preprocess, and will break your whole app if it runs into process <script lang="ts">. This is tracked in issue #116.

Loading

load can be really useful for fetching additional content before component render time. Sometimes, there’s also a case for wanting to do this in a MDSveX layout, say if you want to render a list of related posts. Unfortunately, load is only supported in components that define a page. This means that if you want to fetch some data for each blog post you’d have to do something like this inside each .svx file that uses the layout:

<!-- src/routes/blog/my-post.svx -->
<script context="module">
	import { load } from "./_load"
	export { load }
</script>
/* src/routes/blog/_load.ts */
export async function load({ fetch, page }) {
	// /related.json is an endpoint that returns all related blog posts/projects
	const url = page.path + "/related.json"
	const res = await fetch(url)

	if (res.ok) {
		const { projects } = await res.json()

		return {
			props: {
				relatedProjects: projects
			}
		}
	} else {
		return {
			status: res.status,
			error: new Error("Failed to fetch " + url)
		}
	}
}
<!-- src/routes/blog/_layout.svelte -->
<script>
	export let relatedPosts
</script>

<slot />

{#each relatedPosts as post}
	<li>
		<a href="/blog/{post.slug}" class="text-link">
			{post.title}
		</a>
		<span class="quiet">- {friendlyDate(post.date, true)}</span>
	</li>
{/each}

All of that is kind of roundabout, but it achieves the desired effect.

That’s a wrap

MDSveX helps bridge the gap between a simple personal blog and full blown CMS powered website. It makes writing new blog posts easy without having to add a complex blog writing system to your application. For my use case it’s perfect!

The source code for the site is available on GitHub, linked below. Check it out if you want to learn more.

That’s all for now!


In this article