@skavex/skavex (0.4.1)

Published 2026-09-17 11:02:19 +00:00 by hutao in skavex/skavex

Installation

@skavex:registry=
npm install @skavex/skavex@0.4.1
"@skavex/skavex": "0.4.1"

About this package

skavex

CI Icon Coverage Icon npm Icon Docs Icon Demo Icon License Icon

Server-rendered Markdown + LaTeX for Svelte. A Vite plugin that compiles .md files into real Svelte components — so your posts are HTML on first paint, with no markdown parser in the bundle, no maths rendering on the main thread, and nothing a crawler has to run JavaScript to see.

Documentation · Playground

This README is the tour. The book goes further: component children and indentation, writing plugins, server versus client rendering, the full options reference, and a measured comparison with mdsvex.

The name alternates between the two things it joins:

s ka v ex
S​velte Ka​TeX S​v​elte Ka​TeX

Why

mdsvex is not abandoned — it shipped as recently as 0.12.8. It is stuck, and the reason is worth understanding before you pick either library.

mdsvex implements Svelte support by patching the markdown parser's tokenizer table:

const block_tokenizers = this.Parser.prototype.blockTokenizers;
block_tokenizers.svelteBlock = parse_svelte_block;
block_tokenizers.svelteTag = parse_svelte_tag;

That API is unified 8. remark replaced its parser with micromark in unified 9, and this.Parser, blockTokenizers and blockMethods no longer exist. So mdsvex cannot upgrade without rewriting its Svelte parsing as micromark syntax extensions — hand-written state machines, and the steepest climb in the ecosystem. Patch releases ship; the one that matters cannot.

The cost lands on you as silence. Every modern remark plugin registers itself through data.micromarkExtensions, which mdsvex's parser never reads. Writing to it is legal, so nothing errors — the plugin is simply never consulted:

mdsvex + remark-math 3   46 formulas rendered
mdsvex + remark-math 6    0 formulas rendered   <- no error, no warning

Nothing to search for, nothing in a stack trace. The usual advice is to pin remark-math@3 and rehype-katex@3, but that is not a fix: it pulls in the whole unified 8 tree (unist-util-visit@2, vfile@4) and every modern remark plugin you add afterwards fails the same silent way. The pin does not solve the problem, it returns you to the point where the problem was invisible.

skavex never extends the parser. Component tags arrive as ordinary HTML nodes and are kept intact by a rule about tree nodes — text is prose and gets escaped, raw is deliberate markup and does not. Tree-level work survives a unified major; tokenizer-level work is welded to one. That is the whole difference, and it is why skavex is on unified 11 today and why the version is yours to choose rather than ours to pin.

How it compares

Measured, not claimed — pnpm bench regenerates every number and CI fails if one regresses. Full method in BENCHMARKS.md.

skavex hand-rolled unified 11 mdsvex + math 3 mdsvex + math 6
Formulas rendered 46 46 46 0
Heading ids and TOC data yes no no no
Escapes prose, keeps components yes no no no
Output compiles as Svelte yes no no no
Per document 8.62 ms 6.83 ms 9.31 ms 3.10 ms

Against the only mdsvex that renders maths, skavex is a little quicker — and across repeated runs the two trade places within about 15%, which is noise. Nobody should choose a markdown engine on that; the point is that the feature list below costs nothing in throughput.

The hand-rolled column is a floor, not an alternative. It is the same unified 11 pipeline with none of the work below, and its output does not compile. The 1.26× between them is what that work costs. And remark-math 6 is not fast, it is empty — that column is the price of skipping every formula.

What you are actually choosing is these four, which you would otherwise write and maintain yourself:

  • Braces escaped in prose, untouched in components. Without it the output is not valid Svelte — every row above except skavex fails to compile on prose containing {braces}. mdsvex expects you to escape them by hand, in every document.
  • Components injected by tag, from a directory, so a plugin can emit <YouTube /> without arranging imports.
  • Heading ids and table-of-contents data, derived from prose before KaTeX runs, so an id never changes when KaTeX changes its markup — and with the maths rendered, so navigation is not full of raw LaTeX.
  • A unified version you choose. mdsvex pins unified 8.4.2 (2020), so its maths only works with remark-math@3. Pair it with the current one and it compiles cleanly and emits no maths at all — no error, no warning, nothing to search for.

That last one is why this exists.

Server-rendering matters more than any of it

CLS JavaScript Lighthouse
skavex — maths in the HTML 0.006 0 kB 96
mdsvex + math 3 — also in the HTML 0.006 0 kB 95
client-side KaTeX 0.246 270 kB 84

Read that honestly, in two parts.

skavex and mdsvex are identical here, because both render at build time. The third row is what a project ends up with after the maths silently fails and someone patches it with KaTeX's auto-render script.

The JavaScript column is the durable one. 270 kB against nothing is a count of bytes, the same on every machine. The CLS column is not: these are workstation numbers, and on the CI runner — whose container has one font, so the KaTeX faces land after first paint — the ordering reverses, with the client-rendered page measuring better than the server-rendered ones. BENCHMARKS.md has both sets of numbers and why they disagree.

Install

pnpm add -D @skavex/skavex

Also published to this instance's own registry at the same version — see installing from the Forgejo registry.

Use

skavex is a Vite plugin, so it goes in vite.config.js:

// vite.config.js
import { skavex } from '@skavex/skavex/vite';
import { sveltekit } from '@sveltejs/kit/vite';

export default {
	plugins: [
		// Before sveltekit(): skavex produces Svelte source, which the Svelte
		// plugin then compiles.
		skavex({
			layout: '/src/lib/components/PostLayout.svelte',
			components: '/src/lib/components/md'
		}),
		sveltekit()
	]
};

and svelte.config.js has to recognise the extension:

// svelte.config.js
export default {
	extensions: ['.svelte', '.md']
};

Then import a document like any other component:

const posts = import.meta.glob('/src/content/*.md', { eager: true });
const { default: Post, metadata } = posts['/src/content/hello.md'];

Forgetting .md in extensions is the one failure worth knowing up front. skavex emits valid Svelte, the Svelte plugin ignores it for not being a Svelte file, and the browser is served component source as a module.

Options

Option Type Default Meaning
extensions string[] ['.md'] Which files are documents.
layout string — Component wrapping every document. Gets the metadata as props; the body is its children.
components string — Directory of .svelte files addressable by basename, so plugins can emit <YouTube /> freely.
gfm boolean true Tables, strikethrough, task lists, autolinks.
math boolean | object true LaTeX. An object overrides KaTeX options.
remarkPlugins PluggableList [] Run after frontmatter/GFM/math, before conversion to HTML.
rehypePlugins PluggableList [] Run on the HTML tree before KaTeX, so a plugin reading element text sees prose, not KaTeX markup. Where metadata collectors belong.
root string Vite's root What components resolves against.

Maths renders as HTML and MathML by default. HTML alone looks correct and is completely silent to a screen reader, which makes maths-heavy writing unreadable for anyone using one. Pass math: { output: 'html' } to opt out.

Going the other way is worth knowing about: math: { output: 'mathml' } drops KaTeX's HTML and leaves only the MathML, which every current browser renders natively. On the benchmark corpus that is half the build time and a quarter of the page weight — 5.7 kB against 21 kB of HTML. It is not the default because KaTeX's HTML looks the same regardless of which maths fonts a reader has, but for a maths-heavy site it is the first thing to try.

Metadata

metadata is an open object, and skavex puts nothing of its own in it. YAML frontmatter is one contributor; a plugin is another. What a document exports is whatever the pipeline left there.

A plugin writes file.data.fm, which is vfile's convention rather than an API of skavex's — there is nothing to import:

export function remarkReadingTime() {
	return (tree, file) => {
		file.data.fm = { ...(file.data.fm ?? {}), readingTime: estimate(tree) };
	};
}

Spread what is there rather than assigning over it. That is the whole of the etiquette, and the reason is that a plugin does not know what ran before it — assign, and you discard the author's frontmatter whenever you happen to run second.

A document tree can be asked for a great deal: a table of contents, a reading time, the outbound links, the languages of the code blocks, a word count, the first image, the footnotes. None of it is skavex's to decide or to implement. Remark and rehype exist for exactly this, the ecosystem is full of plugins that already do it, and being on unified 11 is what lets you use them.

Because the shape is the project's, values arrive typed unknown. Narrow them where they are consumed:

const headings = metadata.headings as TocEntry[] | undefined;

Headings and tables of contents

skavex does not do this, and that is the answer rather than an omission. Heading ids are rehype-slug, which handles deduplication properly through github-slugger. A table of contents is a walk over the same tree in whatever shape your navigation needs:

import rehypeSlug from 'rehype-slug';
import { visit } from 'unist-util-visit';
import { toString } from 'hast-util-to-string';

function rehypeToc() {
	return (tree, file) => {
		const toc = [];
		visit(tree, 'element', (node) => {
			const level = Number(/^h([1-6])$/.exec(node.tagName)?.[1]);
			if (level) toc.push({ id: node.properties.id, level, text: toString(node) });
		});
		file.data.fm = { ...(file.data.fm ?? {}), toc };
	};
}

skavex({ rehypePlugins: [rehypeSlug, rehypeToc] });

What skavex contributes is the ordering. rehypePlugins runs before KaTeX, so a heading still reads as $O(\log n)$ - Logarithmic Complexity rather than as <span class="katex">…. Run a slugger after KaTeX and the id is built from KaTeX's markup, changing whenever KaTeX's output does, silently breaking every anchor anyone has shared. That guarantee is the part a library can usefully own; the walk is not.

Earlier versions shipped a rehypeHeadings plugin of their own. It was one project's table of contents living in the core of a library whose scope is unified 11, Svelte, LaTeX and Markdown — and a hand-written slugger that needed bugs fixed into it to approximate what github-slugger already did. The playground's contents plugin is the replacement, editable in the browser: it is the whole feature, in about forty lines, owned by the project that wants it.

Writing a plugin that injects a component

Replace a node with an mdast html node and the component survives to the compiler. @skavex/skavex/utils has the fiddly parts:

import { componentNode, rawHtmlExpression, getBareLinkFromParagraph } from '@skavex/skavex/utils';
import { visit } from 'unist-util-visit';

export function remarkYouTube() {
	return (tree) => {
		visit(tree, 'paragraph', (node, index, parent) => {
			const url = getBareLinkFromParagraph(node);
			if (!url) return;
			parent.children[index] = componentNode('YouTube', { id: idFrom(url) });
		});
	};
}

rawHtmlExpression(html) builds a {@html ...} expression with backticks and ${ escaped — highlighted code contains both, and unescaped they break out of the template literal.

With a components directory configured, nothing else is needed: skavex scans it, sees <YouTube in the output, and emits the import.

How it works

  1. Vite transform on .md, enforce: 'pre' — before the Svelte plugin
  2. Frontmatter → <script module>export const metadata = …</script>
  3. unified: remark-parse → frontmatter → gfm → math → your remark plugins → remark-rehype → your rehype plugins → katex → escape → rehype-stringify
  4. Brace escaping (below)
  5. Wrap in the layout, import referenced components

The brace problem

Svelte reads {…} in markup as an expression. Prose is full of braces — {arr[i]} in a sentence, a code span, and above all KaTeX's MathML <annotation>, which embeds the original LaTeX with every \frac{a}{b} intact. Left alone, a post either fails to compile or quietly evaluates your prose.

skavex escapes braces in hast text nodes and leaves raw nodes alone:

  • text → literal document content → escaped
  • raw → markup a plugin injected on purpose → untouched

That split is the whole contract, and it is why plugins can still inject components. Two details are load-bearing, and both are tested:

  • The replacement is a raw node, not an edited text node. rehype-stringify escapes text on the way out, which would turn &#123; into &#x26;#123; and show the reader a literal entity.
  • Escaping runs after KaTeX, or the annotation's braces are never seen.

Contributing

Development happens on git.hu-tao.dev; GitHub is a push-only mirror whose commits do not survive the next mirror push. Registration on the instance is closed, so opening an issue or a pull request takes an account or an emailed patch — CONTRIBUTING.md has both routes, and what the checks expect.

Development

nix develop          # node, pnpm and browsers, the same versions CI uses
pnpm install
pnpm test            # unit suite
pnpm test:coverage   # with thresholds enforced
pnpm test:e2e        # the demo, driven in a real browser
pnpm bench           # comparison against mdsvex
pnpm demo            # the editor, locally

any is banned. Not discouraged — banned, by jsdoc/reject-any-type and jsdoc/check-types, in source, tests, benchmarks and components alike, because the JSDoc here is not documentation that might drift from the types: it is the type declaration shipped to consumers, and one any disables every check the rest of the config exists to perform. Where a value genuinely is not known, unknown says so and forces the narrowing that any skips.

pnpm check runs three passes: the declaration build over src/, a no-emit pass over everything else, and svelte-check over the demo.

The suite asserts behaviour rather than snapshots: that braces survive as text, that an unbalanced brace really is a Svelte parse error (so the escaping is load-bearing), that KaTeX reaches the server-rendered HTML, and that generated modules compile and render. test/ssr.test.js compiles documents all the way to server-rendered HTML, components included.

Licence

MIT

Dependencies

Dependencies

ID Version
hast-util-to-html ^9.0.5
katex ^0.18.7
rehype-katex ^7.0.1
rehype-stringify ^10.0.1
remark-frontmatter ^5.0.0
remark-gfm ^4.0.1
remark-math ^6.0.0
remark-parse ^11.0.0
remark-rehype ^11.1.2
unified ^11.0.5
unist-util-visit ^5.1.0
vfile ^6.0.3
yaml ^2.9.1

Development dependencies

ID Version
@eslint/js ^10.0.1
@playwright/test 1.63.0
@sveltejs/vite-plugin-svelte ^7.3.0
@types/hast ^3.0.5
@types/mdast ^4.0.4
@types/node ^22.20.3
@typescript/native npm:typescript@^7.0.2
@vitest/coverage-v8 ^5.0.1
eslint ^10.10.0
eslint-config-prettier ^10.1.8
eslint-plugin-jsdoc ^64.5.1
eslint-plugin-svelte ^3.23.0
globals ^17.12.0
hast-util-to-string ^3.0.1
lighthouse ^13.4.1
mdsvex ^0.12.8
prettier ^3.9.7
prettier-plugin-svelte ^4.1.1
rehype-external-links ^3.0.0
rehype-katex-legacy npm:rehype-katex@^3.1.0
rehype-slug ^6.0.0
remark-directive ^4.0.0
remark-math-legacy npm:remark-math@^3.0.1
svelte ^5.57.0
svelte-check ^4.7.6
svelte-eslint-parser ^1.8.1
typescript ^6.0.3
vitest ^5.0.1

Peer dependencies

ID Version
svelte ^5.0.0
vite ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0

Keywords

svelte sveltekit markdown latex katex vite-plugin unified remark rehype ssr
Details
npm
2026-09-17 11:02:19 +00:00
3
Ivan Kirilov <ivan@hu-tao.dev>
MIT
latest
27 KiB
Assets (1)
Versions (2) View all
0.4.1 2026-09-17
0.4.0 2026-09-12