This blog is a directory of .md files, read at build time through Vite's glob import, parsed by marked, highlighted by Shiki 4.4.3, and prerendered by SvelteKit 2 into static HTML. No MDsveX, no CMS, no client-side highlighter — the visitor gets coloured code with zero bytes of JavaScript spent on colouring it. The whole content pipeline is one server module of about 120 lines.
I wrote that module in an afternoon and wrote it up the same week, which was too early. Going back over it with a profiler and a test harness turned up three claims in my own post that were wrong, one measured cost I hadn't budgeted for, and one feature I described that does not exist in the code. This version is the guide and the corrections together, because the corrections are the interesting part.
Do you still need MDsveX for a SvelteKit markdown blog in 2026?#
Only if you want Svelte components inside your prose. That is the entire reason MDsveX exists: it compiles .svx into components so you can drop <Counter /> mid-paragraph. If your posts are headings, paragraphs and code blocks, you are paying for a compile step and a bespoke file format to get a feature you never invoke.
The maintenance argument people make against it is weaker than it used to be, and I should be accurate about that rather than reach for the easy dig. As of writing, mdsvex is on 0.12.8, published 2026-07-19, with a peer range of ^3.56.0 || ^4.0.0 || ^5.0.0-next.120 — so Svelte 5 works. It is maintained. It is also still pre-1.0 after six years, with releases roughly annually, and that is a fair thing to weigh.
My reason for not using it is portability, not health. A .md file renders on GitHub, in an editor preview, through any other static generator, and — the case I actually care about — as a plain-text response at /blog/<slug>.md for crawlers and models. That last route exists on this site precisely because the source is already plain markdown; serving it costs one endpoint and cannot drift from the HTML, because both are rendered from the same string. A .svx file would need compiling before any of that made sense.
The general rule I'd apply: pick the tool for the feature you will use, not the one you might. If a post later needs a live demo, migrating is cheap, because the markdown does not change — only what parses it. I made the same call building an ecommerce site with no framework, and it held up there too.
What import.meta.glob with eager: false actually does#
const files = import.meta.glob('/src/posts/*.md', { query: '?raw', import: 'default' });Vite resolves this at build time into an object mapping each path to a loader function. Adding a post means adding a file — no index to update, no registry to keep in sync. The filesystem is the database.
Here is the first thing I got wrong. I originally wrote that eager: false means "each body is only read when that post is requested". It does not mean that in this codebase, because the very next function loads all of them:
let cache: Post[] | null = null;
async function all(): Promise<Post[]> {
if (cache) return cache;
const loaded = await Promise.all(
Object.entries(files).map(async ([path, load]) => parse(path, (await load()) as string))
);
cache = loaded.sort((a, b) => b.date.localeCompare(a.date));
return cache;
}Every public function — getPosts, getPost, getSlugs — goes through all(). Asking for one post loads, parses and highlights all eight. eager: false defers the file read until something calls the loader; it does nothing about the parse, because the parse is unconditional.
I also claimed eager loading would make the index page "ship the entire blog". That conflates two bundles. This is a .server.ts module, so none of it reaches the browser either way. What protects the client payload is the destructure in getPosts, which drops the rendered HTML and the source before the data is serialised:
export async function getPosts(): Promise<PostMeta[]> {
return (await all()).map(({ html: _html, markdown: _markdown, ...meta }) => meta);
}eager: false still earns its keep — it keeps eight post sources out of the server bundle as inlined string literals — but it is a server-side size argument, not a client-side one.
How do you call an async highlighter from marked's synchronous renderer?#
Shiki's codeToHtml returns a promise. marked's renderer.code is synchronous and cannot await. The version in this repo solves it by highlighting everything up front with a regex, then having the renderer look results up in a Map:
const highlighted = new Map<string, string>();
// Written with ` for the backtick deliberately — see below.
const FENCE = /`{3}(\w+)?\n([\s\S]*?)`{3}/g;
const fences = [...content.matchAll(FENCE)];
await Promise.all(
fences.map(async ([, lang, code]) => {
highlighted.set(`${lang ?? 'text'}:${code}`, await codeToHtml(/* ... */));
})
);It works well enough to have rendered every post you can read here, and it is still the wrong shape, because marked has had a proper seam for this since v4.1 and I did not go looking for it. Setting async: true lets walkTokens be an async function, and marked awaits it over the token tree before parsing:
const md = new Marked({
gfm: true,
async: true,
async walkTokens(token) {
if (token.type !== 'code') return;
token.highlighted = await codeToHtml(token.text, {
lang: token.lang || 'text',
themes: { light: 'github-light', dark: 'github-dark' },
defaultColor: false
});
},
renderer: {
code: (token) => `<div class="code-block">${token.highlighted}</div>`
}
});No regex, no key, no cache. The token already knows its language and its exact text, so there is nothing to match. I ran both against the eight real posts: the regex version highlights 30 blocks and the renderer uses 21 of them. The walkTokens version highlights 30 and uses 30, in the same wall time. Nine highlighted blocks a build were being computed and thrown away, and I had no idea.
The deeper lesson is one I already wrote down in writing a unified plugin for code blocks and then failed to apply here: when a parser hands you a tree, do not go back to matching text. I intervened at the wrong layer in my own blog.
Where the fence-matching regex breaks#
Worth listing, because the failure is silent — a miss falls through to an escaped <pre>, which looks almost right and never throws. Every one of these I confirmed against marked 18.0.9:
- A bare fence with no language. The regex leaves the capture
undefined, so the key becomestext:….markedpasseslang: '', and'' ?? 'text'is'', not'text'— nullish coalescing does not catch the empty string. The key never matches. Six blocks on this site hit that. - Metadata after the language, like a fence tagged
js title="app.js". The pattern requires a newline straight after\w+, so the match fails outright. - Any language id that isn't
\w+—objective-c,c#,shell-session.\wexcludes the hyphen and the hash. - CRLF line endings.
\w+then\ncannot cross the\r. A post written on Windows highlights nothing. - Tilde fences.
markedsupports~~~; the regex has never heard of them. - A fence indented inside a list item. The regex captures the leading spaces;
markedstrips them. Key mismatch. - A code block containing a three-backtick run. The lazy body stops at the first one, and everything after it is off by one.
That last case is not hypothetical. The first published version of this very post showed the pattern inside a TypeScript fence, which desynchronised the matcher from that point on and silently dropped the three code blocks that followed it. That is why the regex above is written with ``{3}` — a regex literal escape for the backtick, functionally identical, and the only way I can print the pattern in an article that is rendered by the pattern.
Does Promise.all make Shiki faster?#
No, and this was my second wrong claim. I wrote that wrapping the highlight calls in Promise.all means "the cost is the slowest block rather than the sum". It does not, because Shiki's tokenising is synchronous CPU work behind an async signature — there is no I/O to interleave.
Measured over the eight posts and 30 blocks in a fresh Node process on my machine:
| approach | time |
|---|---|
Promise.all over all 30 blocks |
661, 692, 660 ms |
plain sequential for loop |
706, 687, 691 ms |
Roughly 4%, which is inside the noise of the runs. Splitting the cost apart explains why:
import { codeToHtml } from 'shiki'— about 55 ms- creating a highlighter with two themes and the eight grammars these posts use — about 106 ms
- tokenising the 30 blocks — about 560 ms, all of it synchronous
The tokenising dominates, and it runs on one thread whatever you wrap it in. The Promise.all is not harmful; it is just decoration. If I wanted this genuinely parallel it would have to be worker threads, and at 30 blocks that is not worth it.
Shiki's dual-theme output, and why defaultColor: false matters#
Shiki uses the same TextMate grammars as VS Code, which is why it gets Go's struct tags, Python's f-strings and GLSL qualifiers right where a regex highlighter mangles them — relevant when the posts on this site include a Go scaffold and matrix parsing in Python.
Two themes plus defaultColor: false is the detail that makes it work with a light/dark toggle:
await codeToHtml(code, {
lang: lang || 'text',
themes: { light: 'github-light', dark: 'github-dark' },
defaultColor: false
});With defaultColor: false, Shiki emits no color property at all — only custom properties, on every token and on the wrapper:
<pre class="shiki shiki-themes github-light github-dark"
style="--shiki-light:#24292e;--shiki-dark:#e1e4e8;--shiki-light-bg:#fff;--shiki-dark-bg:#24292e"
tabindex="0">So the choice is a CSS rule, not a re-highlight:
.shiki,
.shiki span {
color: var(--shiki-light);
background-color: var(--shiki-light-bg);
}
.dark .shiki,
.dark .shiki span {
color: var(--shiki-dark);
background-color: var(--shiki-dark-bg);
}Code follows the site toggle instantly, with no flash and no second tokenising pass. Current Shiki also offers defaultColor: 'light-dark()', which emits the native CSS function instead of two variables — fewer rules, at the cost of tying yourself to prefers-color-scheme rather than a class you control. I want the class, so I have stayed with variables.
One thing worth knowing before you import { codeToHtml } from 'shiki': that entry point is the full bundle. On disk that is 722 grammar files and 132 themes — about 11 MB in @shikijs/langs alone. It never reaches a browser, but it does land in any serverless function that imports this module, which matters in the next section. shiki/core with an explicit grammar list is the fix when it starts to hurt.
The cold start I hadn't budgeted for#
Comments on this site are a separate endpoint, deliberately, so the article itself can stay prerendered while comments load afterwards. The endpoint validates the slug before touching the database:
async function assertRealPost(slug: string) {
const slugs = await getSlugs();
if (!slugs.includes(slug)) error(404, 'No such post');
}getSlugs() goes through all(). So on every cold start of that function, answering "is this slug real?" reads eight markdown files, runs eight marked parses, and hands 30 code blocks to Shiki. Two cold runs measured 708 ms and 755 ms. The second call in the same process is 59 ms, so a warm instance is fine — but the first request after a scale-to-zero pays the whole thing to produce a list of eight strings.
The fix needs no highlighting at all, because the glob keys are known statically:
export function getSlugs(): string[] {
return Object.keys(files).map(slugOf);
}That is the shape of the mistake: a convenience function that quietly depended on the most expensive thing in the module. The article routes want all(); the guard clause on a database write does not, and it was only ever there so the comments table could not accumulate rows keyed to arbitrary strings — the same defensive instinct as putting the constraint in the schema, which I've argued for at length in modelling traffic fines in Postgres.
What entries does for prerendering dynamic routes#
SvelteKit finds pages to prerender by crawling links from its entry points. It cannot enumerate a [slug] parameter on its own — it only knows about /blog/hello-world if something already prerendered links to it. entries is how you hand it the list:
export const prerender = true;
export const entries: EntryGenerator = async () =>
(await getSlugs()).map((slug) => ({ slug }));The same export sits on the /blog/[slug].md endpoint, because prerender applies to +server.js files too. The output is a folder of HTML and markdown files. No server on the request path, no cold start, no database behind an article — one CDN fetch.
The draft filter that never existed#
The first version of this post described drafts as "posts without a body", with a getPosts() that filtered on post.body?.length > 0, and a story about three half-written posts shipping and looking broken.
There is no such filter in this codebase, and no body field — PostMeta carries slug, title, excerpt, category, tags, date, readingMinutes and an optional repo, and the full Post adds html and markdown. Every .md file in src/posts/ is published, full stop. Unpublishing something today means moving the file out of the directory.
I am leaving that here rather than deleting it, because describing code you have not read is the most common way a technical post goes wrong, and it is worth being caught doing it in public. The idea is still one I like — presence of content as the publish flag beats a draft: true you have to remember to flip — but a design I would like is not a feature, and this post presented one as the other for three weeks.
Where this stops working#
At a few hundred posts, the build is the constraint. Every post is re-highlighted on every build, and 560 ms of tokenising for 30 blocks extrapolates badly. The escape is caching highlighted output keyed by content hash, which is exactly the point at which "one server module" stops being an honest description.
The single module-level cache variable is a per-process cache with no invalidation. At build time that is correct. In a long-lived server it would serve stale posts until redeploy — fine here only because nothing edits markdown at runtime.
And if a post ever needs to be edited by someone without repository access, this whole model is wrong rather than merely strained. That is what a CMS is for, and no amount of glob imports substitutes.
Until then the content pipeline is a directory of text files and the deployment artefact is a folder of HTML. That is hard to break — though, as this post demonstrates, not hard to describe incorrectly.
Common questions#
Can I use Shiki in SvelteKit without MDsveX?#
Yes, and you do not need a preprocessor at all. Read the markdown in a .server.ts module via import.meta.glob with query: '?raw', run it through marked or any other parser, call Shiki's codeToHtml during the parse, and mark the route prerender = true. Shiki never reaches the client, because the module never reaches the client.
Why does my Shiki output have no colours?#
Almost certainly defaultColor: false with a themes object. That combination deliberately emits only --shiki-light / --shiki-dark custom properties and no color property, so nothing is coloured until you write a CSS rule reading those variables. Either add the rule, drop defaultColor, or use defaultColor: 'light-dark()'.
How do I highlight code with an async highlighter inside marked?#
Construct Marked with async: true and an async walkTokens that awaits the highlighter and stashes the HTML on the code token; your synchronous renderer.code then just reads it. Do not pre-scan the markdown with a regex and match on the code text — the token already carries the language and the exact body, and every regex you write will miss bare fences, hyphenated language ids, CRLF files and fences containing backticks.
Why is my prerendered [slug] route not being generated?#
SvelteKit only discovers dynamic routes by crawling links on already-prerendered pages. Export an entries: EntryGenerator from the route returning the parameter values, alongside prerender = true. The same applies to +server.js endpoints, which take prerender but are not reached by layouts.
Is build-time highlighting worth it over Prism or highlight.js?#
For a static blog, yes. A runtime highlighter ships a library plus grammars and re-tokenises identical input on every page load, in exchange for nothing the reader can perceive. The trade goes the other way as soon as the code is user-supplied and arrives after build — a paste bin, a comment box, a live editor. Then you want the work at runtime, and Shiki has a browser build for it.