npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@skavex/skavex

v0.4.1

Published

Server-rendered Markdown + LaTeX for Svelte. A Vite plugin that compiles .md into real Svelte components.

Readme

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