mdsvex is the de-facto official markdown preprocessor for Svelte. It is so popular that mdsvex pops up as one of the official add-ons when doing things like creating a new app with npx sv create. Despite all of this, the official documentation is actually pretty unclear about many things (and it’s still using Svelte 4 syntax???), making my journey of setting up the damn thing a smidge harder than it needed to be.
You may have noticed that I don’t have a lot of blog posts from before 2026 even though I started migrating the site to Svelte way earlier. In retrospect, this is almost entirely a result of my unwillingness to interact with mdsvex, because now that I’ve finally set up the blog, WOW do I have things to blog about.
I also want to thank the Svelte discord for their undying patience and support whenever I got stuck.
Layout paths
At the start, my svelte.config.js looked something like this:
svelte.config.js// ... const config = { preprocess: [ vitePreprocess(), mdsvex({ extensions: ['.svx', '.md'], smartypants: {dashes: 'oldschool'}, highlight: { /* shiki stuff */ }, layout: { _: "./Layout.svelte" } }) localizeLinksPreprocessor(), ], extensions: ['.svelte', '.svx', '.md'] // ... }
Layout.svelte’s filepath is indeed relative, which is in accordance to the docs . The docs however doesn’t tell you where these relative paths start from, and apparently neither does mdsvex itself: I ended up having to place the layout file in two different places: one at root, and one next to the markdown file I was reading from. They even throw different errors depending on which of them is missing, but only one of them is actually read by the markdown.
After writing a very, very lengthy report documenting all the odd behaviors surrounding these layout twins and posting it in the Svelte discord, fellow developer Sea Grapes graciously pointed me towards this issue that presented the solution I needed: just use the absolute path, you idiot
svelte.config.js// at the top of the file import { dirname, join } from 'path' import { fileURLToPath } from 'url' const __dirname = dirname(fileURLToPath(import.meta.url)) // later inside the config const layout: { _: join(__dirname, "./MdsvexLayout.svelte") // project root, next to svelte.config.js },
Layout content
If you thought that was the end of my troubles with mdsvex layouts, think again. This next one isn’t even mentioned in official docs.
According to docs, layouts are meant to look like this (converted by me into Svelte 5 because apparently no one cares about updating the doc):
<script module>
// custom components
import { h1, p, li } from './components.js';
export { h1, p, li };
// btw, the docs doesn't tell you what components.js looks like,
// but fortunately it's not that hard to figure out
</script>
<script>
let { title, author, date, children } = $props();
</script>
<h1>{ title }</h1>
<p class="date">on: { date }</p>
<p class="date">by: { author }</p>
{@render children()} <!-- the mdsvex content will be slotted in here --> It looks like frontmatter data is passed in as props to the layout component, so clearly that’s the only place you can access them — inside mdsvex layouts. Which means if I have different categories of markdown content and frontmatters, I need to have different mdsvex layout files for each. A little cumbersome to have 2 files (one +page.svelte and one layout) per category, but whatever.
Next comes actually rendering them in e.g. +page.sveltes. While the docs is also weirdly unclear about this, the idea is that since mdsvex renders markdown into Svelte syntax, you can just import markdown files like any other Svelte components:
<script>
import Content from 'blogpost.md'
</script>
<main>
<Content /> <!-- metadata presumably applies automatically -->
</main> Except that’s not really the case, because the following is also totally fine to do in any Svelte file:
<script>
import Content, { metadata } from 'blogpost.md'
</script>
<h1>{metadata.title}</h1>
<p>{metadata.author}</p>
<main>
<Content />
</main> What???
Again, this is not mentioned by the docs whatsoever. The only reason I found out was from using the ol’ reliable import.meta.glob to grab all markdown files inside my blog directory, and inspecting what the files are exporting. The code I have now at time of writing looks like this:
src/routes/docs/[slug]/+page.svelteconst docs = Object.fromEntries( Object.entries( import.meta.glob( "$data/docs/*.md", // grab all markdown files inside this directory { eager: true } ) ).map(([key, val]) => { const filename = key.replace(/^.+/([^.]+)..+$/i, '$1'); return [filename, val]; // filename is key, value is the return data }) ); let { params } = $props(); // params.slug matches the url /docs/[slug] // e.g. going to /docs/helloworld will grab data from $data/docs/helloworld.md const Content = $derived(docs[params.slug].default); const metadata = $derived(docs[params.slug].metadata);
This is probably not the cleanest solution, but if you take away all the linebreaks it condenses into a nice little unreadable one-liner, and that’s good enough for me.
Also, here’s the components.js I mentioned earlier — this is pretty much the only thing you need in your mdsvex layout.
src/lib/components.jsimport p from './layout/text/Paragraph.svelte'; import h1 from './layout/text/H1.svelte'; import h2 from './layout/text/H2.svelte'; import a from './layout/text/Link.svelte'; import code from './layout/text/Code.svelte'; import ul from './layout/text/UL.svelte'; import ol from './layout/text/OL.svelte'; import strong from './layout/text/Bold.svelte'; export { p, h1, h2, a, code, ul, ol, strong };
MdsvexLayout.svelte<script module> import { h1, h2, p, a, code, ul, ol, strong } from '$lib/components.js'; export { h1, h2, p, a, code, ul, ol, strong }; </script> <script> let { children } = $props(); </script> {@render children()}
Shiki transformers
Shiki is a pretty awesome syntax highlighter I use my on site to render code blocks, such as the ones you’ve seen on this page. Not only does it integrate well with mdsvex nearly out-of-the-box, its docs are also pretty comprehensive.
There are two things I wanted to add in Shiki using transformers : An optional title bar so I can show the filename & filepath, and an optional line counter. Fortunately these can be set using meta strings and Shiki will be able to read them.
Here’s the code for creating a title bar div:
shiki-transformers.jsfunction parseTitleFromMeta(meta) { // look for title="..." or title=... if (!meta) return null; const match = meta.match(/title="([^"]+)"|title=(S+)/); return match ? (match[1] ?? match[2]) : null; } export function transformerTitle() { return { name: 'transformer-title-bar', pre(node) { // node = the <pre> element const title = parseTitleFromMeta(this.options.meta?.__raw); if (!title) return; // Inject a title bar <div> as the FIRST child of <pre> node.children.unshift({ type: 'element', tagName: 'div', properties: { class: 'code-title' }, children: [{ type: 'text', value: title }], }); }, }; }
The line count transformer gets a little more sophisticated: it uses this CSS trick to count numbers. Every year CSS gets closer to becoming a genuine programming language…
Here I also wanted a special behavior: if the meta is just linecount, start counting from 1; if it’s linecount=42, start from 42.
shiki-transformers.jsfunction parseLineCountFromMeta(meta) { // look for linecount or linecount=... if (!meta) return null; const match = meta.match(/linecount(?:=([0-9]+))?/); return match ? (match[1] ?? 'start') : null; } /** @returns {import('shiki').ShikiTransformer} */ export function transformerLineCount() { return { name: 'transformer-line-count', code(node) { const start = parseLineCountFromMeta(this.options.meta?.__raw); if (!start) return; this.addClassToHast(node, 'linecount'); if (start !== 'start') { const style = (node.properties.style) ?? ''; node.properties.style = `${style}counter-increment: step ${start-1};`; } }, }; }
Shiki dark mode
Again, the official docs here is pretty good, only lacking in how it interacts with Svelte/mdsvex specifically (which isn’t a problem).
One possible confusion is that the values in createHighlighter only tell you which themes and langs exist at all, but you need to specify light/dark themes in the codeToHtml option like so:
svelte.config.jsconst themes = { light: 'one-light', dark: 'material-theme-darker' }; const highlighter = await createHighlighter({ themes: ['one-light', 'material-theme-darker'], langs: ['javascript', 'typescript', 'php', 'jsx', 'css', 'svelte'] }); const config = { preprocess: [ vitePreprocess(), mdsvex({ // ... highlight: { highlighter: async (code, lang, meta) => { const html = escapeSvelte(highlighter.codeToHtml(code, { lang, themes, meta: meta ? { __raw: meta } : undefined, transformers: [ transformerTitle(), transformerLineCount(), ] })); return `{@html `${html}` }`; } }, // ... }), // ... ] }
And then simply copy the official CSS snippet and drop it into your app.css directly.