How To Make A Svelte Markdown Preprocessor

Published Apr 5, 2024

Table of Contents

What’s A Preprocessor?

The Svelte documentation describes preprocessors as following:

Preprocessors transform your .svelte files before passing them to the compiler.

To understand what this means, we first have to understand the three parts that make a Svelte component which are:

  • markup
  • <script>
  • <style>

If your component uses TypeScript for example, it has to be transformed to JavaScript first, before it goes through the Svelte compiler — you’re already using vitePreprocess in your Svelte project for handling everything from TypeScript, to PostCSS through Vite.

Another great example is Melt UI which provides a custom preprocessor to enhance the developer experience by reducing boilerplate.

It takes the following code:

example
<div use:melt={$root}>
  <button use:melt={$trigger}>...</button>
  <div use:melt={$content}>...</div>
</div>

…and transforms it to this:

example
<div {...$root} use:$root.action>
  <button {...$trigger} use:$trigger.action>...</button>
  <div {...$content} use:$content.action>...</div>
</div>

mdsvex is a popular Markdown preprocessor for Svelte which transforms Markdown in your Svelte components to HTML — similar to MDX for React.

In the next part we’re going to make a simple Markdown preprocessor for learning purposes.

Creating A Preproccesor

A preprocessor is a regular JavaScript function which can be passed alongside other preprocessors inside the Svelte config.

The preprocessor includes markup, script, and style methods where the order is important — you can use these methods to change parts of the Svelte component you’re interested in.

svelte.config.js
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'

function banana() {
  return {
    name: 'banana',
    markup({ content, filename }) {},
    script({ content, filename }) {},
    style({ content, filename }) {},
  }
}

/** @type {import('@sveltejs/kit').Config} */
const config = {
	preprocess: [vitePreprocess(), banana()],
  // ...
}

export default config

You can name your preprocessor however you want, but keep in mind when you make changes to restart the Vite development server to see updates.

Emoji Preprocessor

Ever dreamed about using emojis to name your variables in Svelte, but are held back by outdated societal norms and limitations of JavaScript? 😔

example
<script>
	let 🔥 = 'fire'
</script>

{🔥}

You can stop dreaming, and make a preprocessor to improve the developer experience because a picture is worth a thousand words.

svelte.config.js
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'

function emoji() {
	return {
		name: 'emoji',
		markup: ({ content }) => ({ code: content.replaceAll('🔥', 'fire') }),
		script: ({ content }) => ({ code: content.replaceAll('🔥', 'fire') }),
	}
}

/** @type {import('@sveltejs/kit').Config} */
const config = {
	preprocess: [vitePreprocess(), emoji()],
  // ...
}

export default config

Congrats on writing your first preprocessor! 🥳

The Markdown Preprocessor

For the Markdown preprocessor the only part we’re interested in is the markup method to transform Markdown to HTML in the Svelte component.

You can also specify a list of file extensions that should be treated as Svelte files — you can use any extension like .banana if you want but I’m going to use .md for Markdown.

svelte.config.js
/** @type {import('@sveltejs/kit').Config} */
const config = {
  extensions: ['.svelte', '.md']
  // ...
}

export default config

Rename +page.svelte to +page.md in your route because we’re going to look for the .md extension to change the content.

The idea behind the Markdown preprocessor is to take a Svelte component with a mix of HTML and Markdown:

+page.md
<script>
  import Counter from './counter.svelte'
</script>

# Counter

<Counter />

…and convert it to HTML:

+page.md
<script>
  import Counter from './counter.svelte'
</script>

<h1>Counter</h1>

<Counter />

The only part left to do is convert Markdown to HTML and replace the content.

Transforming Markdown To HTML

I’m going to use unified which is an ecosystem of plugins that helps you inspect and transform content with plugins:

It’s helpful but you don’t have to understand how abstract syntax trees work — they’re just a data structure that uses nodes to represent code.

Create the markdown preprocessor inside src/lib/markdown.js to keep things organized.

src/lib/markdown.js
import { parse } from 'svelte/compiler'
import { unified } from 'unified'
import rehypeStringify from 'rehype-stringify'
import remarkParse from 'remark-parse'
import remarkRehype from 'remark-rehype'

async function markdownToHtml(string) {
	return (
		unified()
			// turn Markdown into mdast
			.use(remarkParse)
			// turn Markdown (mdast) into HTML (hast)
			.use(remarkRehype, { allowDangerousHtml: true })
			// turn HTML (hast) into HTML string
			.use(rehypeStringify, { allowDangerousHtml: true })
			// process the string
			.process(string)
	)
}

async function html(content) {
	const svast = parse(content)
	const { start, end } = svast.html
	const string = content.slice(start, end)
	const html = await markdownToHtml(string)

	return {
		code: content.replace(string, html),
	}
}

function markdown() {
	return {
		name: 'markdown',
		markup({ content, filename }) {
			if (filename.endsWith('.md')) {
				return html(content)
			}
		},
	}
}

export default markdown

Here’s how it works:

  • markdown only transforms .md files
  • html turns the Svelte component into an abstract syntax tree, so we get the start, and end point of the markup to slice
  • markdownToHtml transforms the Markdown:
    • Markdown > mdast (Markdown AST) > hast (HTML AST) > HTML string
  • replace the Markdown string with the transformed html

The reason for using allowDangerousHtml is because otherwise it would strip out things like the <script> tag.

Not only can we combine preprocessors, but you can also pass options to them if you want.

svelte.config.js
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'

import emoji from './src/lib/emoji.js'
import markdown from './src/lib/markdown.js'

/** @type {import('@sveltejs/kit').Config} */
const config = {
	extensions: ['.svelte', '.md'],
	preprocess: [vitePreprocess(), emoji(), markdown()],
	kit: {
		adapter: adapter(),
	},
}

export default config

The Markdown preprocessor should transform Markdown in your Svelte components to regular HTML.

+page.md
<script>
	import Counter from './counter.svelte'
	let 🔥 = 'Counter'
</script>

# {🔥}

<Counter />

That’s it! 😄

Support

You can subscribe on YouTube, or consider becoming a patron if you want to support my work.

Patreon
Found a mistake?

Every post is a Markdown file so contributing is simple as following the link below and pressing the pencil icon inside GitHub to edit it.

Edit on GitHub