Markdown with Svelte components, without mdsvex
I write in markdown, but a paragraph isn't always enough. Sometimes the point needs something live: a counter, a little diagram, a demo that actually runs inside the article instead of sitting behind a screenshot.
Most people reach for mdsvex. It's the standard answer, and it works fine — except it hasn't kept pace with Svelte 5. This site is deep in Svelte 5 territory: runes everywhere, the experimental async compiler, SvelteKit remote functions. Rather than patching an abandoned layer, I wrote my own. It turned out to be about 150 lines.
Preprocessors don't care about extensions
Svelte's preprocessor doesn't check what extension a file has. Register .md as a Svelte extension, hand the compiler a preprocessor that turns markdown into markup, and suddenly your blog post is just another component. That's the whole trick.
sveltekit({
extensions: [".svelte", ".md"],
preprocess: [markdown()],
compilerOptions: {
runes: true,
experimental: { async: true },
},
experimental: { remoteFunctions: true },
});The preprocessor pulls frontmatter and exports it as metadata so server code can import a post's title and date. It lifts <script> blocks out untouched — that's how a post imports a component. It renders the rest with marked. And it highlights code blocks with Shiki at build time, so the browser never loads a syntax highlighter.
The whole thing hangs on this markup hook:
export function markdown(): PreprocessorGroup {
return {
name: "markdown",
async markup({ content, filename }) {
if (!filename?.endsWith(".md")) return;
const { metadata, body } = parseFrontmatter(content);
const { scripts, body: stripped } = extractScripts(body);
const html = await renderMarkdown(stripped);
return { code: assemble(metadata, scripts, html) };
},
};
}The only genuinely annoying part is braces. Svelte sees { as the start of an expression, so every curly brace inside a highlighted code block has to be escaped into an HTML entity before the compiler touches it. Miss one, and your first TypeScript snippet takes the whole page down.
Components just work
Because the post is a Svelte component after preprocessing, embedding something is just an import and a tag:
<script lang="ts">
import Counter from "$lib/components/Counter.svelte";
</script>
<Counter />And here it is, running in this post:
count is 0, doubled is 0
No iframe, no sandbox — it's the actual component hydrating in the article body, running the same runes as everything else on the site. If a component can do it, a post can do it.
Highlighting in both themes
Shiki bakes both light and dark colors into each token inline. A few lines of CSS flip between them with prefers-color-scheme. I'm using everforest-light and everforest-dark — warm, low-contrast, and green enough to sit inside the site's palette instead of fighting it.
That's the pipeline. No plugin ecosystem to depend on, no upstream migration to wait for — just ~150 lines I fully understand, and that I can bend when the next post needs something weird.
